├── .coveralls.yml ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ ├── feature_request.md │ └── pull_request_template.md ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── doc ├── appendix-01.md ├── step-01.md ├── step-02.md ├── step-03.md ├── step-04.md ├── step-05.md ├── step-06.md ├── step-07.md ├── step-08.md ├── step-09.md ├── step-10.md ├── step-11.md ├── step-12.md ├── step-13.md ├── step-14.md ├── step-15.md └── step-16.md ├── images ├── AccountRepository.png ├── AccountRepository2.png ├── data-jpa-paging.png ├── intellij-properties.png ├── jpa-class-diagram.png ├── junit5-display-name.png ├── postman-page-request.png ├── querydsl-path.png ├── search-paging.png ├── spring-profile.png ├── swagger-paging.png └── test-result.png ├── lombok.config ├── mvnw ├── mvnw.cmd ├── pom.xml ├── rest └── account │ ├── account_create.json │ ├── account_update.json │ └── accounts.http └── src ├── main ├── java │ └── com │ │ └── cheese │ │ └── springjpa │ │ ├── Account │ │ ├── api │ │ │ └── AccountController.java │ │ ├── application │ │ │ └── AccountService.java │ │ ├── dao │ │ │ ├── AccountFindService.java │ │ │ ├── AccountRepository.java │ │ │ ├── AccountSearchService.java │ │ │ ├── AccountSupportRepository.java │ │ │ └── AccountSupportRepositoryImpl.java │ │ ├── domain │ │ │ ├── Account.java │ │ │ ├── Address.java │ │ │ ├── Email.java │ │ │ └── Password.java │ │ ├── dto │ │ │ ├── AccountDto.java │ │ │ └── AccountSearchType.java │ │ └── exception │ │ │ ├── AccountNotFoundException.java │ │ │ ├── EmailDuplicationException.java │ │ │ └── PasswordFailedExceededException.java │ │ ├── SpringJpaApplication.java │ │ ├── common │ │ └── model │ │ │ ├── DateTime.java │ │ │ └── PageRequest.java │ │ ├── config │ │ ├── SwaggerConfig.java │ │ └── WebSecurityConfig.java │ │ ├── coupon │ │ ├── Coupon.java │ │ ├── CouponRepository.java │ │ └── CouponService.java │ │ ├── delivery │ │ ├── Delivery.java │ │ ├── DeliveryController.java │ │ ├── DeliveryDto.java │ │ ├── DeliveryLog.java │ │ ├── DeliveryRepository.java │ │ ├── DeliveryService.java │ │ ├── DeliveryStatus.java │ │ └── exception │ │ │ ├── DeliveryAlreadyDeliveringException.java │ │ │ ├── DeliveryNotFoundException.java │ │ │ └── DeliveryStatusEqaulsException.java │ │ ├── error │ │ ├── ErrorCode.java │ │ ├── ErrorExceptionController.java │ │ └── ErrorResponse.java │ │ ├── order │ │ ├── Order.java │ │ ├── OrderController.java │ │ ├── OrderRepository.java │ │ └── OrderService.java │ │ └── properties │ │ ├── AntiSamplePropertiesRunner.java │ │ ├── SampleProperties.java │ │ └── SamplePropertiesRunner.java └── resources │ ├── application-dev.yml │ ├── application-local.yml │ ├── application-prod.yml │ ├── application.yml │ └── init.sql └── test └── java └── com └── cheese └── springjpa ├── Account ├── AccountFindServiceTest.java ├── AccountIntegrationTest.java ├── AccountRepositoryTest.java └── AccountServiceJUnit5Test.java ├── SpringJpaApplicationTests.java ├── account ├── AccountControllerTest.java ├── AccountServiceTest.java └── model │ ├── EmailTest.java │ └── PasswordTest.java ├── common └── model │ ├── DateTimeTest.java │ └── PageRequestTest.java ├── coupon └── CouponTest.java ├── delivery ├── DeliveryControllerTest.java ├── DeliveryLogTest.java └── DeliveryServiceTest.java └── order └── OrderServiceTest.java /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: oTJ7CTNqWrWtEy83kkjqtvA4gkhDXCngW -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull Request 3 | about: Pull Request 4 | 5 | --- 6 | 7 | ## 테스트 8 | * [x] 블라블라 9 | * [x] 블라블라 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | 4 | ### STS ### 5 | .apt_generated 6 | .classpath 7 | .factorypath 8 | .project 9 | .settings 10 | .springBeans 11 | .sts4-cache 12 | 13 | ### IntelliJ IDEA ### 14 | .idea/ 15 | *.iws 16 | *.iml 17 | *.ipr 18 | 19 | ### NetBeans ### 20 | /nbproject/private/ 21 | /build/ 22 | /nbbuild/ 23 | /dist/ 24 | /nbdist/ 25 | /.nb-gradle/ 26 | ### Eclipse template 27 | 28 | .metadata 29 | bin/ 30 | tmp/ 31 | *.tmp 32 | *.bak 33 | *.swp 34 | *~.nib 35 | local.properties 36 | .settings/ 37 | .loadpath 38 | .recommenders 39 | 40 | # External tool builders 41 | .externalToolBuilders/ 42 | 43 | # Locally stored "Eclipse launch configurations" 44 | *.launch 45 | 46 | # PyDev specific (Python IDE for Eclipse) 47 | *.pydevproject 48 | 49 | # CDT-specific (C/C++ Development Tooling) 50 | .cproject 51 | 52 | # CDT- autotools 53 | .autotools 54 | 55 | # Java annotation processor (APT) 56 | .factorypath 57 | 58 | # PDT-specific (PHP Development Tools) 59 | .buildpath 60 | 61 | # sbteclipse plugin 62 | .target 63 | 64 | # Tern plugin 65 | .tern-project 66 | 67 | # TeXlipse plugin 68 | .texlipse 69 | 70 | # STS (Spring Tool Suite) 71 | .springBeans 72 | 73 | # Code Recommenders 74 | .recommenders/ 75 | 76 | # Scala IDE specific (Scala & Java development for Eclipse) 77 | .cache-main 78 | .scala_dependencies 79 | .worksheet 80 | ### Example user template template 81 | ### Example user template 82 | 83 | # IntelliJ project files 84 | .idea 85 | *.iml 86 | out 87 | gen 88 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | jdk: 3 | - openjdk8 4 | 5 | env: 6 | global: 7 | - CODECOV_TOKEN = c94b61dd-a2a3-434f-bfda-a5679aaa2177 8 | install: true 9 | 10 | script: mvn install 11 | 12 | before_install: 13 | - mvn clean 14 | 15 | 16 | branches: 17 | only: 18 | - master 19 | 20 | after_success: 21 | - mvn clean test jacoco:report coveralls:report 22 | - bash <(curl -s https://codecov.io/bash) 23 | 24 | notifications: 25 | slack: cheese-dev:JXKSTVY4wHsMPbJi2uoKtyxs -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## 1. Purpose 4 | 5 | A primary goal of Spring Jpa Best Practices is to be inclusive to the largest number of contributors, with the most varied and diverse backgrounds possible. As such, we are committed to providing a friendly, safe and welcoming environment for all, regardless of gender, sexual orientation, ability, ethnicity, socioeconomic status, and religion (or lack thereof). 6 | 7 | This code of conduct outlines our expectations for all those who participate in our community, as well as the consequences for unacceptable behavior. 8 | 9 | We invite all those who participate in Spring Jpa Best Practices to help us create safe and positive experiences for everyone. 10 | 11 | ## 2. Open Source Citizenship 12 | 13 | A supplemental goal of this Code of Conduct is to increase open source citizenship by encouraging participants to recognize and strengthen the relationships between our actions and their effects on our community. 14 | 15 | Communities mirror the societies in which they exist and positive action is essential to counteract the many forms of inequality and abuses of power that exist in society. 16 | 17 | If you see someone who is making an extra effort to ensure our community is welcoming, friendly, and encourages all participants to contribute to the fullest extent, we want to know. 18 | 19 | ## 3. Expected Behavior 20 | 21 | The following behaviors are expected and requested of all community members: 22 | 23 | * Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community. 24 | * Exercise consideration and respect in your speech and actions. 25 | * Attempt collaboration before conflict. 26 | * Refrain from demeaning, discriminatory, or harassing behavior and speech. 27 | * Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential. 28 | * Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations. 29 | 30 | ## 4. Unacceptable Behavior 31 | 32 | The following behaviors are considered harassment and are unacceptable within our community: 33 | 34 | * Violence, threats of violence or violent language directed against another person. 35 | * Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language. 36 | * Posting or displaying sexually explicit or violent material. 37 | * Posting or threatening to post other people’s personally identifying information ("doxing"). 38 | * Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability. 39 | * Inappropriate photography or recording. 40 | * Inappropriate physical contact. You should have someone’s consent before touching them. 41 | * Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances. 42 | * Deliberate intimidation, stalking or following (online or in person). 43 | * Advocating for, or encouraging, any of the above behavior. 44 | * Sustained disruption of community events, including talks and presentations. 45 | 46 | ## 5. Consequences of Unacceptable Behavior 47 | 48 | Unacceptable behavior from any community member, including sponsors and those with decision-making authority, will not be tolerated. 49 | 50 | Anyone asked to stop unacceptable behavior is expected to comply immediately. 51 | 52 | If a community member engages in unacceptable behavior, the community organizers may take any action they deem appropriate, up to and including a temporary ban or permanent expulsion from the community without warning (and without refund in the case of a paid event). 53 | 54 | ## 6. Reporting Guidelines 55 | 56 | If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a community organizer as soon as possible. cheese10yun@gmail.com. 57 | 58 | 59 | 60 | Additionally, community organizers are available to help community members engage with local law enforcement or to otherwise help those experiencing unacceptable behavior feel safe. In the context of in-person events, organizers will also provide escorts as desired by the person experiencing distress. 61 | 62 | ## 7. Addressing Grievances 63 | 64 | If you feel you have been falsely or unfairly accused of violating this Code of Conduct, you should notify Cheese10yun with a concise description of your grievance. Your grievance will be handled in accordance with our existing governing policies. 65 | 66 | 67 | 68 | ## 8. Scope 69 | 70 | We expect all community participants (contributors, paid or otherwise; sponsors; and other guests) to abide by this Code of Conduct in all community venues–online and in-person–as well as in all one-on-one communications pertaining to community business. 71 | 72 | This code of conduct and its related procedures also applies to unacceptable behavior occurring outside the scope of community activities when such behavior has the potential to adversely affect the safety and well-being of community members. 73 | 74 | ## 9. Contact info 75 | 76 | cheese10yun@gmail.com 77 | 78 | ## 10. License and attribution 79 | 80 | This Code of Conduct is distributed under a [Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/). 81 | 82 | Portions of text derived from the [Django Code of Conduct](https://www.djangoproject.com/conduct/) and the [Geek Feminism Anti-Harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy). 83 | 84 | Retrieved on November 22, 2016 from [http://citizencodeofconduct.org/](http://citizencodeofconduct.org/) 85 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## CONTRIBUTING 2 | 3 | * test 4 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Master Pull Reuqest 2 | * (#issue) 3 | 4 | ## Test 5 | - [] 로그인/로그아웃 6 | - [] 송금 신청 7 | - [] 송금 취소 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/cheese10yun/spring-jpa-best-practices.svg?branch=master)](https://travis-ci.org/cheese10yun/spring-jpa-best-practices) 2 | [![Coverage Status](https://coveralls.io/repos/github/cheese10yun/spring-jpa-best-practices/badge.svg?branch=master)](https://coveralls.io/github/cheese10yun/spring-jpa-best-practices?branch=master) 3 | [![codecov](https://codecov.io/gh/cheese10yun/spring-jpa-best-practices/branch/master/graph/badge.svg)](https://codecov.io/gh/cheese10yun/spring-jpa-best-practices) 4 | [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fcheese10yun%2Fspring-jpa-best-practices&count_bg=%2379C83D&title_bg=%23555555&icon=github.svg&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) 5 | 6 | # Spring-Jpa Best Practices 7 | 8 | 스프링으로 개발을하면서 제가 느낀 점들에 대해서 간단하게 정리했습니다. **아직 부족한 게 많아 Best Practices라도 당당하게 말하긴 어렵지만, 저와 같은 고민을 하시는 분들에게 조금이라도 도움이 되고 싶어 이렇게 정리했습니다.** 지속해서 해당 프로젝트를 이어 나아갈 예정이라 깃허브 Start, Watching 버튼을 누르시면 구독 신청받으실 수 있습니다. 저의 경험이 여러분에게 조금이라도 도움이 되기를 기원합니다. 9 | 10 | 11 | ## 목차 12 | 1. [step-01 : Account 생성, 조회, 수정 API를 간단하게 만드는 예제](https://github.com/cheese10yun/spring-jpa/blob/master/doc/step-01.md) 13 | 2. [step-02 : 효과적인 validate, 예외 처리 (1)](https://github.com/cheese10yun/spring-jpa/blob/master/doc/step-02.md) 14 | 3. [step-03 : 효과적인 validate, 예외 처리 (2)](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-03.md) 15 | 4. [step-04 : Embedded를 이용한 Password 처리](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-04.md) 16 | 5. [step-05: OneToMany 관계 설정 팁](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-05.md) 17 | 6. [step-06: Setter 사용하지 않기](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-06.md) 18 | 7. [step-07: Embedded를 적극 활용](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-07.md) 19 | 8. [step-08: OneToOne 관계 설정 팁](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-08.md) 20 | 9. [step-09: OneToMany 관계 설정 팁(2)](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-09.md) 21 | 10. [step-10: Properties 설정값 가져오기](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-10.md) 22 | 11. [step-11: Properties environment 설정하기](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-11.md) 23 | 12. [step-12: 페이징 API 만들기](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-12.md) 24 | 13. [step-13: Query Dsl이용한 페이징 API 만들기](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-13.md) 25 | 14. [step-14: JUnit 5적용하기](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-14.md) 26 | 15. [step-15: Querydsl를 이용해서 Repository 확장하기(1)](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-15.md) 27 | 16. [step-16: Querydsl를 이용해서 Repository 확장하기(2)](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-16.md) 28 | 29 | **step-XX Branch 정보를 의미합니다. 보고 싶은 목차의 Branch로 checkout을 해주세요** 30 | 31 | 32 | ## 질문 33 | ![](https://i.imgur.com/Y4t4oWM.png) 34 | 35 | * Github Issue를 통해서 이슈를 등록해주시면 제가 아는 부분에 대해서는 최대한 답변드리겠습니다. 36 | 37 | ## 개발환경 38 | * Spring boot 1.5.8.RELEASE 39 | * Java 8 40 | * JPA & H2 41 | * lombok 42 | * maven 43 | 44 | ## 프로젝트 실행환경 45 | 46 | * Lombok이 반드시 설치 되있어야 합니다. 47 | - [Eclipse 설치 : [lombok] eclipse(STS)에 lombok(롬복) 설치](http://countryxide.tistory.com/16) 48 | - [Intell J 설치 : [Intellij] lombok 사용하기](http://blog.woniper.net/229) 49 | 50 | ### 실행 51 | ``` 52 | $ mvn spring-boot:run 53 | ``` 54 | 55 | ### API Swagger 56 | ![](https://i.imgur.com/1cc1auF.png) 57 | 해당 API는 Swagger [http://localhost:8080/swagger-ui.html](http://localhost:8080/swagger-ui.html)으로 테스트해 볼 수 있습니다. 58 | -------------------------------------------------------------------------------- /doc/appendix-01.md: -------------------------------------------------------------------------------- 1 | # Appendix 2 | 3 | ## LocalDateTime 설정하기 4 | 5 | ### 클래스 설정 6 | ```java 7 | @EntityScan(basePackageClasses = {Application.class, Jsr310JpaConverters.class}) //등록 8 | @SpringBootApplication 9 | public class RefactoringApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(RefactoringApplication.class, args); 13 | } 14 | } 15 | ``` 16 | * 메인 클래스에서 설정 17 | ### Json 포메팅 18 | 19 | ``` 20 | { 21 | "expirationDate": [ // 포멧팅전 22 | 2018, 23 | 12, 24 | 12, 25 | 0, 26 | 0 27 | ], 28 | } 29 | ``` 30 | 31 | ```josn 32 | { 33 | "expirationDate": "2018-12-12T00:00:00", // 변경후 34 | } 35 | ``` 36 | 37 | 38 | ### 메이븐 39 | ``` 40 | 41 | com.fasterxml.jackson.datatype 42 | jackson-datatype-jsr310 43 | 44 | ``` 45 | 46 | ### 프로퍼티 설정 47 | ```yml 48 | spring: 49 | jackson: 50 | serialization: 51 | WRITE_DATES_AS_TIMESTAMPS: false 52 | ``` 53 | 54 | -------------------------------------------------------------------------------- /doc/step-01.md: -------------------------------------------------------------------------------- 1 | # Step-01 Account 생성, 조회, 수정 API를 간단하게 만드는 예제 2 | Spring Boot + JPA를 활용한 Account 생성, 조회, 수정 API를 간단하게 만드는 예제입니다. 해당 코드는 [spring-jpa](https://github.com/cheese10yun/spring-jpa)를 확인해주세요. 3 | 4 | ## 중요 포인트 5 | * 도메인 클래스 작성 6 | * DTO 클래스를 이용한 Request, Response 7 | * Setter 사용안하기 8 | 9 | ## 도메인 클래스 작성 : Account Domain 10 | ```java 11 | @Entity 12 | @Table(name = "account") 13 | @Getter 14 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 15 | public class Account { 16 | 17 | @Id 18 | @GeneratedValue 19 | private long id; 20 | 21 | @Column(name = "email", nullable = false, unique = true) 22 | private String email; 23 | 24 | ... 25 | ... 26 | ... 27 | 28 | @Column(name = "zip", nullable = false) 29 | private String zip; 30 | 31 | @Column(name = "created_at") 32 | @Temporal(TemporalType.TIMESTAMP) 33 | private Date createdAt; 34 | 35 | @Column(name = "updated_at") 36 | @Temporal(TemporalType.TIMESTAMP) 37 | private Date updatedAt; 38 | 39 | @Builder 40 | public Account(String email, String fistName, String lastName, String password, String address1, String address2, String zip) { 41 | this.email = email; 42 | this.fistName = fistName; 43 | this.lastName = lastName; 44 | this.password = password; 45 | this.address1 = address1; 46 | this.address2 = address2; 47 | this.zip = zip; 48 | } 49 | 50 | public void updateMyAccount(AccountDto.MyAccountReq dto) { 51 | this.address1 = dto.getAddress1(); 52 | this.address2 = dto.getAddress2(); 53 | this.zip = dto.getZip(); 54 | } 55 | } 56 | ``` 57 | ### 제약조건 맞추기 58 | 칼럼에 대한 제약조건을 생각하며 작성하는 하는 것이 바람직합니다. 대표적으로 `nullable`, `unique` 조건등 해당 디비의 스키마와 동일하게 설정하는 것이 좋습니다. 59 | 60 | ### 생성날짜, 수정날짜 값 설정 못하게 하기 61 | 기본적으로 `setter` 메서드가 모든 멤버 필드에 대해서 없고 생성자를 이용한 Builder Pattern 메서드에도 생성, 수정 날짜를 제외해 `@CreationTimestamp`, `@UpdateTimestamp` 어노테이션을 이용해서 VM시간 기준으로 날짜가 자동으로 입력하게 하거나 데이터베이스에서 자동으로 입력하게 설정하는 편이 좋습니다. 매번 생성할 때 create 시간을 넣어 주고, update 할 때 넣어 주고 반복적인 작업과 실수를 줄일 수 있는 효과적인 방법이라고 생각합니다. 62 | 63 | ### 객체 생성 제약 64 | `@NoArgsConstructor(access = AccessLevel.PROTECTED)` lombok 어노테이션을 통해서 객체의 직접생성을 외부에서 못하게 설정하였습니다. 그래서 `@Builder` 에노티이션이 설정돼 있는 `Account` 생성자 메소드를 통해서 해당 객체를 생성할 수 있습니다. 이렇게 빌더 패턴을 이용해서 객체 생성을 강요하면 다음과 같은 장점이 있습니다. ( Account 생성자의 모든 인자값을 넣어주면 생성은 가능합니다.) 65 | 66 | #### 객체를 유연하게 생성할 수 있습니다. 67 | ```java 68 | Account.builder() 69 | .address1("서울") 70 | .address2("성동구") 71 | .zip("052-2344") 72 | .email("email") 73 | .fistName("yun") 74 | .lastName("kim") 75 | .password("password111") 76 | .build(); 77 | ``` 78 | * 객체를 생성할 때 인자 값의 순서가 상관없습니다. 79 | * 입력되는 값이 정확히 어떤 값인지 알 수 있습니다. 80 | - address1() 자연스럽게 address1에 입력되는 것을 알 수 있습니다. 81 | * 하나의 생성자로 대체가 가능합니다. 82 | - 여러 생성자를 두지 않고 하나의 생성자를 통해서 객체 생성이 가능합니다. 83 | 84 | ## DTO 클래스를 이용한 Request, Response 85 | 86 | ### DTO 클래스 87 | ```java 88 | public class AccountDto { 89 | @Getter 90 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 91 | public static class SignUpReq { 92 | private String email; 93 | ... 94 | private String address2; 95 | private String zip; 96 | 97 | @Builder 98 | public SignUpReq(String email, String fistName, String lastName, String password, String address1, String address2, String zip) { 99 | this.email = email; 100 | ... 101 | this.address2 = address2; 102 | this.zip = zip; 103 | } 104 | 105 | public Account toEntity() { 106 | return Account.builder() 107 | .email(this.email) 108 | ... 109 | .address2(this.address2) 110 | .zip(this.zip) 111 | .build(); 112 | } 113 | 114 | } 115 | 116 | @Getter 117 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 118 | public static class MyAccountReq { 119 | private String address1; 120 | private String address2; 121 | private String zip; 122 | 123 | @Builder 124 | public MyAccountReq(String address1, String address2, String zip) { 125 | this.address1 = address1; 126 | this.address2 = address2; 127 | this.zip = zip; 128 | } 129 | 130 | } 131 | 132 | @Getter 133 | public static class Res { 134 | private String email; 135 | ... 136 | private String address2; 137 | private String zip; 138 | 139 | public Res(Account account) { 140 | this.email = account.getEmail(); 141 | ... 142 | this.address2 = account.getAddress2(); 143 | this.zip = account.getZip(); 144 | } 145 | } 146 | } 147 | ``` 148 | 149 | ### DTO 클래스의 필요 이유 150 | Account에 정보를 변경하는 API가 있다고 가정 했을 경우 RequestBody를 Account 클래스로 받게 된다면 다음과 같은 문제가 발생합니다. 151 | 152 | * 데이터 안전성 153 | - 정보 변경 API에서는 firstName, lastName 두 속성만 변경할 수 있다고 했으면 Account 클래스로 RequestBody를 받게 된다면 email, password, Account 클래스의 모든 속성값들을 컨트롤러를 통해서 넘겨받을 수 있게 되고 원치 않은 데이터 변경이 발생할 수 있습니다. 154 | - firstName, lastName 속성 이외의 값들이 넘어온다면 그것은 잘못된 입력값이고 그런 값들을 넘겼을 경우 Bad Request 처리하는 것이 안전합니다. 155 | - Response 타입이 Account 클래스일 경우 계정의 모든 정보가 노출 되게 됩니다. JsonIgnore 속성들을 두어 임시로 막는 것은 바람직하지 않습니다.속성들을 두어 임시로 막는 것은 바람직하지 않습니다. 156 | * 명확해지는 요구사항 157 | - MyAccountReq 클래스는 마이 어카운트 페이지에서 변경할 수 있는 값들로 address1, address2, zip 속성이 있습니다. 요구사항이 이 세 가지 속성에 대한 변경이어서 해당 API가 어떤 값들을 변경할 수가 있는지 명확해집니다. 158 | 159 | ### 컨트롤러에서의 DTO 160 | 161 | ```java 162 | @RequestMapping(method = RequestMethod.POST) 163 | @ResponseStatus(value = HttpStatus.CREATED) 164 | public AccountDto.Res signUp(@RequestBody final AccountDto.SignUpReq dto) { 165 | return new AccountDto.Res(accountService.create(dto)); 166 | } 167 | 168 | @RequestMapping(value = "/{id}", method = RequestMethod.GET) 169 | @ResponseStatus(value = HttpStatus.OK) 170 | public AccountDto.Res getUser(@PathVariable final long id) { 171 | return new AccountDto.Res(accountService.findById(id)); 172 | } 173 | 174 | @RequestMapping(value = "/{id}", method = RequestMethod.PUT) 175 | @ResponseStatus(value = HttpStatus.OK) 176 | public AccountDto.Res updateMyAccount(@PathVariable final long id, @RequestBody final AccountDto.MyAccountReq dto) { 177 | return new AccountDto.Res(accountService.updateMyAccount(id, dto)); 178 | } 179 | ``` 180 | ![](https://i.imgur.com/pbhdpcV.png) 181 | 위에서 언급했듯이 Request 값과 Response 값이 명확하게 되어 API 또 한 명확해집니다. 위 그림처럼 swagger API Document를 사용한다면 Request 값과 Response 자동으로 명세 되는 장점 또한 있습니다. 182 | 183 | 184 | 185 | ## Setter 사용안하기 186 | JPA에서는 영속성이 있는 객체에서 Setter 메서드를 통해서 데이터베이스 DML이 가능하게 됩니다. 만약 무분별하게 모든 필드에 대한 Setter 메서드를 작성했을 경우 email 변경 기능이 없는 기획 의도가 있더라도 영속성이 있는 상태에서 Setter 메서드를 사용해서 얼마든지 변경이 가능해지는 구조를 갖게 됩니다. 또 굳이 변경 기능이 없는 속성뿐만이 아니라 영속성만 있으면 언제든지 DML이 가능한 구조는 안전하지 않다고 생각합니다. 또 데이터 변경이 발생했을 시 추적할 포인트들도 많아집니다. DTO 클래스를 기준으로 데이터 변경이 이루어진다면 명확한 요구사항에 의해서 변경이 된다고 생각합니다. 187 | 188 | ```java 189 | // setter 이용 방법 190 | public Account updateMyAccount(long id) { 191 | final Account account = findById(id); 192 | account.setAddress1("변경..."); 193 | account.setAddress2("변경..."); 194 | account.setZip("변경..."); 195 | return account; 196 | } 197 | // Dto 이용 방법 198 | public Account updateMyAccount(long id, AccountDto.MyAccountReq dto) { 199 | final Account account = findById(id); 200 | account.updateMyAccount(dto); 201 | return account; 202 | } 203 | // Account 클래스의 일부 204 | public void updateMyAccount(AccountDto.MyAccountReq dto) { 205 | this.address1 = dto.getAddress1(); 206 | this.address2 = dto.getAddress2(); 207 | this.zip = dto.getZip(); 208 | } 209 | ``` 210 | DTO 클래스를 이용해서 데이터 변경을 하는 것이 훨씬더 직관적이고 유지보수 하기 쉽다고 생각합니다. MyAccountReq 클래스에는 3개의 필드가 있으니 오직 3개의 필드만 변경이 가능하다는 것이 아주 명확해집니다. 211 | 212 | 여기서 제가 중요하다고 생각하는 것은 `updateMyAccount(AccountDto.MyAccountReq dto)` 메소드입니다. **객체 자신을 변경하는 것은 언제나 자기 자신이어야 한다는 OOP 관점에서 도메인 클래스에 updateMyAccount 기능이 있는 것이 맞는다고 생각합니다.** 213 | 214 | ## 마무리 215 | 최근 스프링을 6개월 가까이 하면서 제가 느낀 점들에 대해서 간단하게 정리했습니다. **아직 부족한 게 많아 Best Practices라도 당당하게 말하긴 어렵지만, 저와 같은 고민을 하시는 분들에게 조금이라도 도움이 되고 싶어 이렇게 정리했습니다.** 또 Step-02에서는 예외 처리와 유효성 검사에 대한 것을 정리할 예정입니다. 지속해서 해당 프로젝트를 이어 나아갈 예정이라 깃허브 start, watch 버튼을 누르시면 구독 신청받으실 수 있습니다. 저의 경험이 여러분에게 조금이라도 도움이 되기를 기원합니다. 216 | -------------------------------------------------------------------------------- /doc/step-02.md: -------------------------------------------------------------------------------- 1 | # Step-02 효과적인 validate, 예외 처리 (1) 2 | 3 | API을 개발하다 보면 프런트에서 넘어온 값에 대한 유효성 검사를 수없이 진행하게 됩니다. 이러한 **반복적인 작업을 보다 효율적으로 처리하고 정확한 예외 메시지를 프런트엔드에게 전달해주는 것이 목표입니다**. 4 | 5 | ## 중요 포인트 6 | 7 | * `@Valid`를 통한 유효성검사 8 | * `@ControllerAdvice`를 이용한 Exception 핸들링 9 | * `ErrorCode` 에러 메시지 통합 10 | 11 | ## @Valid 를 통한 유효성검사 12 | 13 | ### DTO 유효성 검사 어노테이션 추가 14 | ```java 15 | @Getter 16 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 17 | public static class SignUpReq { 18 | @Email 19 | private String email; 20 | @NotEmpty 21 | private String fistName; 22 | ... 23 | @NotEmpty 24 | private String zip; 25 | } 26 | ``` 27 | 이전 단계에서 작성한 회원가입을 위한 SignUpReq.class에 새롭게 추가된 `@Email`, `@NotEmpty` 어노테이션을 추가했습니다. 이 밖에 다양한 어노테이션들이 있습니다. 아래의 컨트롤러에서 `@Valid` 어노테이션을 통해서 유효성 검사를 진행하고 유효성 검사를 실패하면 `MethodArgumentNotValidException` 예외가 발생합니다. 28 | 29 | ### Controller에서 유효성 검사 30 | ```java 31 | @RequestMapping(method = RequestMethod.POST) 32 | @ResponseStatus(value = HttpStatus.CREATED) 33 | public AccountDto.Res signUp(@RequestBody @Valid final AccountDto.SignUpReq dto) { 34 | return new AccountDto.Res(accountService.create(dto)); 35 | } 36 | ``` 37 | 컨트롤러에 `@Valid` 어노테이션을 추가했습니다. `SignUpReq` 클래스의 유효성 검사가 실패했을 경우 38 | `MethodArgumentNotValidException` 예외가 발생하게 됩니다. **프론트에서 넘겨받은 값에 대한 유효성 검사는 39 | 엄청난 반복적인 작업이며 실패했을 경우 사용자에게 적절한 Response 값을 리턴해주는 것 또한 중요 비즈니스 로직이 아님에도 불구하고 40 | 많은 시간을 할애하게 됩니다.** 다음 부분은 `MethodArgumentNotValidException` 발생시 공통적으로 **사용자에게 적절한 Response 값을 리턴해주는 작업을 진행하겠습니다.** 41 | 42 | 43 | ## @ControllerAdvice를 이용한 Exception 핸들링 44 | 45 | ```Java 46 | @ControllerAdvice 47 | public class ErrorExceptionController { 48 | @ExceptionHandler(MethodArgumentNotValidException.class) 49 | @ResponseStatus(HttpStatus.BAD_REQUEST) 50 | protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { 51 | retrun errorResponse... 52 | } 53 | } 54 | ``` 55 | `@ControllerAdvice` 어노테이션을 추가하면 특정 Exception을 핸들링하여 적절한 값을 Response 값으로 리턴해줍니다. 위처럼 별다른 `MethodArgumentNotValidException` 핸들링을 하지 않으면 스프링 자체의 에러 Response 값을 아래와 같이 리턴해줍니다. 56 | 57 | ### Error Response 58 | ```json 59 | { 60 | "timestamp": 1525182817519, 61 | "status": 400, 62 | "error": "Bad Request", 63 | "exception": "org.springframework.web.bind.MethodArgumentNotValidException", 64 | "errors": [ 65 | { 66 | "codes": [ 67 | "Email.signUpReq.email", 68 | "Email.email", 69 | "Email.java.lang.String", 70 | "Email" 71 | ], 72 | "arguments": [ 73 | { 74 | "codes": [ 75 | "signUpReq.email", 76 | "email" 77 | ], 78 | "arguments": null, 79 | "defaultMessage": "email", 80 | "code": "email" 81 | }, 82 | [], 83 | { 84 | "arguments": null, 85 | "defaultMessage": ".*", 86 | "codes": [ 87 | ".*" 88 | ] 89 | } 90 | ], 91 | "defaultMessage": "이메일 주소가 유효하지 않습니다.", 92 | "objectName": "signUpReq", 93 | "field": "email", 94 | "rejectedValue": "string", 95 | "bindingFailure": false, 96 | "code": "Email" 97 | } 98 | ], 99 | "message": "Validation failed for object='signUpReq'. Error count: 3", 100 | "path": "/accounts" 101 | } 102 | ``` 103 | 너무나 많은 값을 돌려보내 주고 있으며 시스템 정보에 대한 값들도 포함되고 있어 위처럼 Response 값을 돌려보내는 것은 바람직하지 않습니다. 또 자체적으로 돌려보내 주는 Response 결과를 공통적인 포맷으로 가져가는 것은 최종적으로 프론트 엔드에서 처리해야 하므로 항상 공통적인 Response 포맷을 유지해야 합니다. 아래 `Error Response` 클래스를 통해서 공통적인 예외 Response 값을 갖도록 하겠습니다. 104 | 105 | ### MethodArgumentNotValidException의 Response 처리 106 | 107 | ```java 108 | @ExceptionHandler(MethodArgumentNotValidException.class) 109 | @ResponseStatus(HttpStatus.BAD_REQUEST) 110 | protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { 111 | log.error(e.getMessage()); 112 | final BindingResult bindingResult = e.getBindingResult(); 113 | final List errors = bindingResult.getFieldErrors(); 114 | 115 | return buildFieldErrors( 116 | ErrorCode.INPUT_VALUE_INVALID, 117 | errors.parallelStream() 118 | .map(error -> ErrorResponse.FieldError.builder() 119 | .reason(error.getDefaultMessage()) 120 | .field(error.getField()) 121 | .value((String) error.getRejectedValue()) 122 | .build()) 123 | .collect(Collectors.toList()) 124 | ); 125 | } 126 | ``` 127 | ### ErrorResponse 128 | ```Java 129 | @Getter 130 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 131 | public class ErrorResponse { 132 | private String message; 133 | private String code; 134 | private int status; 135 | private List errors; 136 | ... 137 | 138 | @Getter 139 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 140 | public static class FieldError { 141 | private String field; 142 | private String value; 143 | private String reason; 144 | ... 145 | } 146 | } 147 | ``` 148 | 149 | 전체적인 흐름을 보시는 것을 권장합니다. 대충 소스코드의 흐름은 MethodArgumentNotValidException 클래스의 유효성 예외 부분들을 `ErrorResponse` 클래스의 정보에 알맞게 넣어주는 것입니다. 150 | 151 | ### ErrorResponse : 공통적인 예외 Response 152 | ```json 153 | { 154 | "message": "입력값이 올바르지 않습니다.", 155 | "code": "???", 156 | "status": 400, 157 | "errors": [ 158 | { 159 | "field": "email", 160 | "value": "string", 161 | "reason": "이메일 주소가 유효하지 않습니다." 162 | }, 163 | { 164 | "field": "lastName", 165 | "value": null, 166 | "reason": "반드시 값이 존재하고 길이 혹은 크기가 0보다 커야 합니다." 167 | }, 168 | { 169 | "field": "fistName", 170 | "value": null, 171 | "reason": "반드시 값이 존재하고 길이 혹은 크기가 0보다 커야 합니다." 172 | } 173 | ] 174 | } 175 | ``` 176 | 동일한 ErrorResponse 값을 갖게 되었으며 어느 칼럼에서 무슨 무슨 문제들이 발생했는지 알 수 있게 되었습니다. `@Valid` 어노테이션으로 발생하는 `MethodArgumentNotValidException`들은 모두 handleMethodArgumentNotValidException 메서드를 통해서 공통된 Response 값을 리턴합니다. **이제부터는 @Valid, 해당 필드에 맞는 어노테이션을 통해서 모든 유효성 검사를 진행할 수 있습니다.** 177 | 178 | 179 | 180 | ### AccountNotFoundException : 새로운 Exception 정의 181 | 182 | ```java 183 | public class AccountNotFoundException extends RuntimeException { 184 | private long id; 185 | 186 | public AccountNotFoundException(long id) { 187 | this.id = id; 188 | } 189 | } 190 | 191 | public Account findById(long id) { 192 | final Optional account = accountRepository.findById(id); 193 | account.orElseThrow(() -> new AccountNotFoundException(id)); 194 | return account.get(); 195 | } 196 | ``` 197 | 198 | ### handleAccountNotFoundException : 핸들링 199 | ```java 200 | @ExceptionHandler(value = { 201 | AccountNotFoundException.class 202 | }) 203 | @ResponseStatus(HttpStatus.NOT_FOUND) 204 | protected ErrorResponse handleAccountNotFoundException(AccountNotFoundException e) { 205 | final ErrorCode accountNotFound = ErrorCode.ACCOUNT_NOT_FOUND; 206 | log.error(accountNotFound.getMessage(), e.getMessage()); 207 | return buildError(accountNotFound); 208 | } 209 | ``` 210 | 211 | ### Response 212 | ```json 213 | { 214 | "message": "해당 회원을 찾을 수 없습니다.", 215 | "code": "AC_001", 216 | "status": 404, 217 | "errors": [] 218 | } 219 | ``` 220 | 위처럼 새로운 Exception 정의하고 핸들링할 수 있습니다. 이 또 한 공통된 Response 갖게 되며 예외가 발생했을 경우 throw를 통해 해당 Exception 잘 처리해 주는 곳으로 던지게 됨으로써 비즈니스 로직과 예외 처리를 하는 로직이 분리되어 코드 가독성 및 유지 보수에 좋다고 생각합니다. 221 | 222 | ## ErrorCode 223 | ```Java 224 | @Getter 225 | public enum ErrorCode { 226 | 227 | ACCOUNT_NOT_FOUND("AC_001", "해당 회원을 찾을 수 없습니다.", 404), 228 | EMAIL_DUPLICATION("AC_002", "이메일이 중복되었습니다.", 400), 229 | INPUT_VALUE_INVALID("CM_001", "입력값이 올바르지 않습니다.", 400); 230 | 231 | private final String code; 232 | private final String message; 233 | private final int status; 234 | 235 | ErrorCode(String code, String message, int status) { 236 | this.code = code; 237 | this.message = message; 238 | this.status = status; 239 | } 240 | } 241 | ``` 242 | 위 방법은 깃허브에서 많은 개발자들이 예외 처리를 하는 방법들의 장점들을 합쳐서 만든 방법이지만 이 에러 코드는 저의 생각으로만 만들어진 방법이라서 효율적인 방법인지는 아직 잘 모르겠습니다. 우선 각각 모두 흩어져있는 예외 메시지들을 한 곳에서 관리하는 것이 바람직하다고 생각합니다. 그 이유는 다음과 같습니다. 243 | 244 | 1. 중복적으로 작성되는 메시지들이 너무 많습니다. 245 | - 예를 들어 `해당 회원을 찾을 수 없습니다.` 메시지를 로그에 남기는 메시지 형태는 너무나도 많은 형태입니다. 246 | 2. 메시지 변경이 힘듭니다. 247 | - 메시지가 스트링 형식으로 모든 소스에 흩어져있을 경우 메시지 변경 시에 모든 곳을 다 찾아서 변경해야 합니다. 248 | 249 | 250 | ## 단점 251 | 위의 유효성 검사의 단점은 다음과 같습니다. 252 | 253 | 1. 모든 Request Dto에 대한 반복적인 유효성 검사의 어노테이션이 필요합니다. 254 | - 회원 가입, 회원 정보 수정 등등 지속적으로 DTO 클래스가 추가되고 그때마다 반복적으로 어노테이션이 추가됩니다. 255 | 2. 유효성 검사 로직이 변경되면 모든 곳에 변경이 따른다. 256 | - 만약 비밀번호 유효성 검사가 특수문자가 추가된다고 하면 비밀번호 변경에 따른 유효성 검사를 정규 표현식의 변경을 모든 DTO마다 해줘야 합니다. 257 | 258 | 이러한 단점들은 다음 `step-03 : 효과적인 validate, 예외 처리 처리 (2)`에서 다루어 보겠습니다. 지속적으로 포스팅이어 가겠습니다. 긴 글 읽어주셔서 감사합니다. 259 | -------------------------------------------------------------------------------- /doc/step-03.md: -------------------------------------------------------------------------------- 1 | # step-03 : 효과적인 validate, 예외 처리 (2) 2 | 3 | 이전 포스팅의 단점을 해결해서 더 효과적인 validate, 예외 처리 작업을 진행해보겠습니다. 4 | 5 | ## [step-02 : 이전 포스팅의 단점](https://github.com/cheese10yun/spring-jpa/blob/master/doc/step-02.md) 6 | 7 | 1. 모든 Request Dto에 대한 반복적인 유효성 검사의 어노테이션이 필요합니다. 8 | - 회원 가입, 회원 정보 수정 등등 지속적으로 DTO 클래스가 추가되고 그때마다 반복적으로 어노테이션이 추가됩니다. 9 | 2. 유효성 검사 로직이 변경되면 모든 곳에 변경이 따른다. 10 | - 만약 비밀번호 유효성 검사가 특수문자가 추가된다고 하면 비밀번호 변경에 따른 유효성 검사를 정규 표현식의 변경을 모든 DTO마다 해줘야 합니다. 11 | 12 | 13 | ## 중요포인트 14 | 15 | * @Embeddable / @Embedded 16 | * DTO 변경 17 | 18 | ## @Embeddable / @Embedded 19 | 20 | ### @Embeddable / @Embedded 적용 21 | 22 | ```java 23 | public class Account { 24 | @Embedded 25 | private com.cheese.springjpa.Account.model.Email email; 26 | } 27 | 28 | @Embeddable 29 | public class Email { 30 | 31 | @org.hibernate.validator.constraints.Email 32 | @Column(name = "email", nullable = false, unique = true) 33 | private String address; 34 | } 35 | ``` 36 | 37 | 임베디드 키워드를 통해서 새로운 값 타입을 집적 정의해서 사용할 수 있습니다. Email 클래스를 새로 생성하고 거기에 Email 칼럼에 매핑하였습니다. 38 | 39 | ## DTO 변경 40 | 41 | ### AccountDto.class 42 | ```java 43 | public static class SignUpReq { 44 | 45 | // @Email 기존코드 46 | // private String email; 47 | @Valid // @Valid 반드시 필요 48 | private com.cheese.springjpa.Account.model.Email email; 49 | 50 | private String zip; 51 | @Builder 52 | public SignUpReq(com.cheese.springjpa.Account.model.Email email, String fistName, String lastName, String password, String address1, String address2, String zip) { 53 | this.email = email; 54 | ... 55 | this.zip = zip; 56 | } 57 | 58 | public Account toEntity() { 59 | return Account.builder() 60 | .email(this.email) 61 | ... 62 | .zip(this.zip) 63 | .build(); 64 | } 65 | } 66 | ``` 67 | 68 | 69 | 모든 Request Dto에 대한 반복적인 유효성 검사의 어노테이션이 필요했었지만 **새로운 Email 클래스를 바라보게 변경하면 해당 클래스의 이메일 유효성 검사를 바라보게 됩니다.** 그 결과 이메일에 대한 유효성 검사는 Embeddable 타입의 Email 클래스가 관리하게 됩니다. 물론 이메일 유효성 검사는 로직이 거의 변경할 일이 없지만 다른 입력값들은 변경할 일들이 자주 생깁니다. 이럴 때 모든 DTO에 가서 유효성 로직을 변경하는 것은 불편한 것을 넘어서 불안한 구조를 갖게 됩니다. 관리 포인트를 줄이는 것은 제가 생각했을 때는 되게 중요하다고 생각합니다. 70 | 71 | 72 | ## 단점 73 | 물론 이것 또한 단점이 없는 건 아닙니다. 아래 json처럼 email json 포멧팅이 변경되신 걸 확인할 수 있습니다. 물론 jackson을 사용해서 root element 조정을 할 수 있지만 그다지 추천해주고 싶지는 않습니다. 74 | ```json 75 | { 76 | "address1": "string", 77 | "address2": "string", 78 | "email": { 79 | "address": "string" 80 | }, 81 | "fistName": "string", 82 | "lastName": "string", 83 | "password": "string", 84 | "zip": "string" 85 | } 86 | ``` 87 | 88 | ## 결론 89 | 포스팅에는 유효성 검사를 하기 위해서 임베디드 타입을 분리했지만 사실 이런 이점보다는 다른 이점들이 많습니다. 또 이러한 이유로만 분리하지도 않는 걸로 알고 있습니다. 잘 설계된 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다고들 합니다. 제가 생각했을 때 진정한 장점은 다음과 같다고 생각합니다. 90 | 91 | Account 엔티티는 fistName, lastName, password, address1, address2, zip 갖는 자입니다. 하지만 이러한 단순한 정보로 풀어 둔 것 일뿐. 데이터의 연관성이 없습니다. 아래처럼 정리하는 것이 더 바람직하다고 생각합니다. 92 | 93 | Account 엔티티는 이름, 비밀번호, 주소를 갖는다. 여기에 필요한 상세 정보들은 주소라는 임베디드 타입에 정의돼있으면 된다고 생각합니다. 해당 설명을 json으로 풀어쓰면 아래와 같을 거같습니다. 94 | 95 | ```json 96 | { 97 | "address": { 98 | "address1": "string", 99 | "address2": "string", 100 | "zip": "string" 101 | }, 102 | "email": { 103 | "address": "string" 104 | }, 105 | "name":{ 106 | "first": "name", 107 | "last": "name" 108 | }, 109 | "password": "string" 110 | } 111 | ``` 112 | Account가 상세한 데이터를 그대로 가지고 있는 것은 객체지향적이지 않으며 응집력만 떨어뜨리는 결과를 갖는다고 생각합니다. 저는 ORM JPA 기술은 단순히 반복적인 쿼리문을 대신 작성해주는 것이라고 생각하지는 않고 데이터를 데이터베이스에서만 생각하지 않고 그것을 객체지향적으로 바라보아 결국 객체지향 프로그래밍을 돕는다고 생각합니다. 113 | 114 | 115 | ## 참고 116 | * [자바 ORM 표준 JPA 프로그래밍 ](http://www.kyobobook.co.kr/product/detailViewKor.laf?ejkGb=KOR&mallGb=KOR&barcode=9788960777330&orderClick=LAH&Kc=) 117 | -------------------------------------------------------------------------------- /doc/step-04.md: -------------------------------------------------------------------------------- 1 | # step-04 : Embedded를 이용한 Password 처리 2 | 3 | 이번 포스팅에서는 Embedded를 이용해서 Password 클래스를 통해서 Password 관련 응집력을 높이는 방법과 JPA에서 LocalDateTime을 활용하는 방법에 대해서 중점으로 포스팅을 진행해 보겠습니다. 4 | 5 | 6 | ## 중요포인트 7 | * Embeddable 타입의 Password 클래스 정의 8 | 9 | 10 | ## Embeddable 타입의 Password 클래스 정의 11 | 12 | ### 비밀번호 요구사항 13 | * 비밀번호 만료 기본 14일 기간이 있다. 14 | * 비밀번호 만료 기간이 지나는 것을 알 수 있어야 한다. 15 | * 비밀번호 5회 이상 실패했을 경우 더 이상 시도를 못하게 해야 한다. 16 | * 비밀번호가 일치하는 경우 실패 카운트를 초기화 해야한다. 17 | * 비밀번호 변경시 만료일이 현재시간 기준 14일로 연장되어야한다. 18 | 19 | 20 | ```java 21 | @Embeddable 22 | public class Password { 23 | @Column(name = "password", nullable = false) 24 | private String value; 25 | 26 | @Column(name = "password_expiration_date") 27 | private LocalDateTime expirationDate; 28 | 29 | @Column(name = "password_failed_count", nullable = false) 30 | private int failedCount; 31 | 32 | @Column(name = "password_ttl") 33 | private long ttl; 34 | 35 | @Builder 36 | public Password(final String value) { 37 | this.ttl = 1209_604; // 1209_604 is 14 days 38 | this.value = encodePassword(value); 39 | this.expirationDate = extendExpirationDate(); 40 | } 41 | 42 | public boolean isMatched(final String rawPassword) { 43 | if (failedCount >= 5) 44 | throw new PasswordFailedExceededException(); 45 | 46 | final boolean matches = isMatches(rawPassword); 47 | updateFailedCount(matches); 48 | return matches; 49 | } 50 | 51 | public void changePassword(final String newPassword, final String oldPassword) { 52 | if (isMatched(oldPassword)) { 53 | value = encodePassword(newPassword); 54 | extendExpirationDate(); 55 | } 56 | } 57 | } 58 | ``` 59 | 60 | **객체의 변경이나 질의는 반드시 해당 객체에 의해서 이루어져야 하는데 위의 요구 사항을 만족하는 로직들은 Password 객체 안에 있고 Password 객체를 통해서 모든 작업들이 이루어집니다.** 그래서 결과적으로 Password 관련 테스트 코드도 작성하기 쉬워지고 이렇게 작은 단위로 테스트 코드를 작성하면 실패했을 때 원인도 찾기 쉬워집니다. 61 | 62 | 결과적으로 Password의 책임이 명확해집니다. 만약 Embeddable 타입으로 분리하지 않았을 경우에는 해당 로직들은 모두 Account 클래스에 들어가 Account 책임이 증가하는 것을 방지할 수 있습니다. 63 | 64 | 65 | ## 소소한 팁 66 | * 날짜 관련 클래스는 LocalDateTime 사용하였습니다. 설정 방법은 [링크](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/appendix-01.md)에서 확인해주세요 67 | * LocalDateTime.now().plusSeconds(ttl); 현재 시간에서 시간 초만큼 더하는 함수입니다. 정말 직관적이며 다른 좋은 함수들이 있어 꼭 프로젝트에 도입해보시는 것을 추천드립니다. 68 | 69 | ## 결론 70 | 굳이 Password 에만 해당하는 경우가 아니라 핵심 도메인들을 Embeddable을 분리해서 책임을 분리하고 응집력, 재사용성을 높이는 것이 핵심 주제였습니다. 꼭 개인 프로젝트에서라도 핵심 도메인을 성격에 맞게끔 분리해 보시는 것을 경험해보시길 바랍니다. -------------------------------------------------------------------------------- /doc/step-05.md: -------------------------------------------------------------------------------- 1 | # step-05: OneToMany 관계 설정 팁 2 | 3 | 배송이 있고 배송의 상태를 갖는 배송 로그가 있고 각각의 관계는 1:N 관계입니다. 아래와 같은 특정한 1:N 관계에 대해서 포스팅을 진행해보겠습니다. 4 | 5 | ## 배송 - 배송 로그 6 | * 배송이 있고 배송의 상태를 갖는 배송 로그가 있습니다. 7 | * 각각의 관계는 1:N 관계입니다. 8 | * 다음과 같은 JSON을 갖습니다. 9 | ```json 10 | { 11 | "address": { 12 | "address1": "서울 특별시...", 13 | "address2": "신림 ....", 14 | "zip": "020...." 15 | }, 16 | "logs": [ 17 | { 18 | "status": "PENDING" 19 | }, 20 | { 21 | "status": "DELIVERING" 22 | }, 23 | { 24 | "status": "COMPLETED" 25 | } 26 | ] 27 | } 28 | ``` 29 | 배송 로그는 단순히 배송의 상태를 표시하기 위한 것임으로 배송 엔티티에서 추가되는 것이 맞는다고 생각합니다. 위의 특성을 만족하는 관계 설정을 진행해보겠습니다. 30 | 31 | ## 관계 설정 32 | 33 | ```java 34 | public class Delivery { 35 | 36 | @Embedded 37 | private Address address; 38 | 39 | @OneToMany(mappedBy = "delivery", cascade = CascadeType.PERSIST, orphanRemoval = true, fetch = FetchType.EAGER) 40 | private List logs = new ArrayList<>(); 41 | 42 | @Embedded 43 | private DateTime dateTime; 44 | .... 45 | } 46 | 47 | public class DeliveryLog { 48 | 49 | @Enumerated(EnumType.STRING) 50 | @Column(name = "status", nullable = false, updatable = false) 51 | private DeliveryStatus status; 52 | 53 | @ManyToOne 54 | @JoinColumn(name = "delivery_id", nullable = false, updatable = false) 55 | private Delivery delivery; 56 | 57 | @Embedded 58 | private DateTime dateTime; 59 | 60 | .... 61 | } 62 | ``` 63 | * @Embedded 타입으로 빼놓은 Address를 그대로 사용했습니다. 이처럼 핵심 도메인에 대해서 데이터의 연관성이 있는 것들을 Embedded 분리해놓으면 여러모로 좋습니다. 64 | * DateTime 클래스도 Embedded 타입으로 지정해서 반복적인 생성일, 수정일 칼럼들을 일관성 있고 편리하게 생성할 수 있습니다. 65 | 66 | **지금부터는 1:N 관계 팁에 관한 이야기를 진행하겠습니다.** 67 | 68 | * Delivery를 통해서 DeliveryLog를 관리함으로 `CascadeType.PERSIST` 설정을 주었습니다. 69 | * 1: N 관계를 맺을 경우 List를 주로 사용하는데 객체 생성을 null로 설정하는 것보다 `new ArrayList<>();`설정하는 것이 바람직합니다. 이유는 다음과 같습니다. 70 | 71 | ```java 72 | private void verifyStatus(DeliveryStatus status, Delivery delivery) { 73 | if (!delivery.getLogs().isEmpty()) { 74 | ... 75 | } 76 | } 77 | ``` 78 | * 초기화하지 않았을 경우 null로 초기화되며 ArrayList에서 지원해주는 함수를 사용할 수 없습니다. 1:N 관계에서 N이 없는 경우 null인 상태인 보다 Empty 상태가 훨씬 직관적입니다. null의 경우 값을 못 가져온 것인지 값이 없는 것인지 의미가 분명하지 않습니다. 79 | 80 | ```java 81 | public void addLog(DeliveryStatus status) { 82 | logs.add(DeliveryLog.builder() 83 | .status(status) 84 | .delivery(this) 85 | .build()); 86 | } 87 | ``` 88 | * CascadeType.PERSIST 설정을 주면 Delivery에서 DeliveryLog를 저장시킬 수 있습니다. 이 때 ArrayList 형으로 지정돼 있다면 add 함수를 통해서 쉽게 저장할 수 있습니다. 이렇듯 ArrayList의 다양한 함수들을 사용할 수 있습니다. 89 | * FetchType.EAGER 통해서 모든 로그 정보를 가져오고 있습니다. 로그 정보가 수십 개 이상일 경우는 Lazy 로딩을 통해서 가져오는 것이 좋지만 3~4개 정도로 가정했을 경우 FetchType.EAGER로 나쁘지 않다고 생각합니다. 90 | 91 | ## 객체의 상태는 언제나 자기 자신이 관리합니다. 92 | 93 | ```java 94 | public class DeliveryLog { 95 | @Id 96 | @GeneratedValue 97 | private long id; 98 | .... 99 | 100 | private void cancel() { 101 | verifyNotYetDelivering(); 102 | this.status = DeliveryStatus.CANCELED; 103 | } 104 | 105 | private void verifyNotYetDelivering() { 106 | if (isNotYetDelivering()) throw new DeliveryAlreadyDeliveringException(); 107 | } 108 | 109 | private void verifyAlreadyCompleted() { 110 | if (isCompleted()) 111 | throw new IllegalArgumentException("It has already been completed and can not be changed."); 112 | } 113 | } 114 | ``` 115 | 객체의 상태는 언제나 자기 자신이 관리합니다. 즉 자신이 생성되지 못할 이유도 자기 자신이 관리해야 한다고 생각합니다. 위의 로직은 다음과 같습니다. 116 | * cancel() : 배송을 취소하기 위해서는 아직 배달이 시작하기 이전의 상태여야 가능합니다. 117 | * verifyAlreadyCompleted() : 마지막 로그가 COMPLETED 경우 더는 로그를 기록할 수 없습니다. 118 | 119 | 즉 자신이 생성할 수 없는 이유는 자기 자신이 갖고 있어야 합니다. 이렇게 되면 어느 곳에서 생성하든 같은 기준으로 객체가 생성되어 생성 관리 포인트가 한 곳에서 관리됩니다. 120 | 121 | ## 배송 로그 저장 122 | 123 | ```java 124 | public class DeliveryService { 125 | 126 | public Delivery create(DeliveryDto.CreationReq dto) { 127 | final Delivery delivery = dto.toEntity(); 128 | delivery.addLog(DeliveryStatus.PENDING); 129 | return deliveryRepository.save(delivery); 130 | } 131 | 132 | public Delivery updateStatus(long id, DeliveryDto.UpdateReq dto) { 133 | final Delivery delivery = findById(id); 134 | delivery.addLog(dto.getStatus()); 135 | return delivery; 136 | } 137 | } 138 | ``` 139 | * create : Delivery 클래스를 생성하고 delivery.addLog를 PENDING 상태로 생성하고 Repository의 save 메소드를 통해서 저장할 수 있습니다. 최종적인 JSON 값은 아래와 같습니다. 140 | 141 | ```json 142 | { 143 | "address": { 144 | "address1": "서울 특별시...", 145 | "address2": "신림 ....", 146 | "zip": "020...." 147 | }, 148 | "logs": [ 149 | { 150 | "status": "PENDING" 151 | } 152 | ] 153 | } 154 | ``` 155 | * updateStatus : 해당 객체를 데이터베이스에서 찾고 해당 배송 객체에 배송 로그를 추가합니다. 배송 로그에 추가적인 로그 저장은 `delivery.addLog(..);` 메서드를 통해서 진행됩니다. 언제나 관리 포인트를 줄이는 것은 중요하다고 생각됩니다. 156 | 157 | 158 | ## 마무리 159 | 코드 양이 많아지고 있어서 반드시 전체 코드와 테스트 코드를 돌려 보면서 이해하는 것을 추천해 드립니다. 이전 포스팅에서도 언급한 적 있지만 소스코드에서는 setter 메서드를 사용하지 않고 있습니다. 무분별하게 setter 메서드를 남용하는 것은 유지 보수와 가독성을 떨어트린다고 생각합니다. 다음 포스팅에서는 setter를 사용하지 않는 장점에 대해서 조금 더 깊게 설명해 보겠습니다. 160 | -------------------------------------------------------------------------------- /doc/step-06.md: -------------------------------------------------------------------------------- 1 | # step-06: Setter 사용하지 않기 2 | 객체지향 언어에서 관습처럼 setter를 추가하는 때도 있습니다. 무분별하게 setter를 사용하는 것은 바람직하지 않다고 생각합니다. 특히 도메인 객체들에는 더더욱이 말입니다. 이번 포스팅에서는 무분별한 setter의 단점과 setter를 이용하지 않고 도메인 객체를 변경하는 방법을 소개하겠습니다. 3 | 4 | 5 | ## Setter 메소드는 의도를 갖기 힘듭니다. 6 | 7 | ### Setter를 이용한 업데이트 8 | ```java 9 | public Account updateMyAccount(long id, AccountDto.MyAccountReq dto) { 10 | final Account account = findById(id); 11 | account.setAddress("value"); 12 | account.setFistName("value"); 13 | account.setLastName("value"); 14 | return account; 15 | } 16 | ``` 17 | 위의 코드는 회원 정보의 성, 이름, 주소를 변경하는 코드로 여러 setter 메소드들이 나열돼있습니다. 위 setter들은 회원 정보를 변경하기 위한 나열이라서 메소드들의 의도가 명확히 드러나지 않습니다. 18 | 19 | ### updateMyAccount 메서드를 이용한 업데이트 20 | ```java 21 | public Account updateMyAccount(long id, AccountDto.MyAccountReq dto) { 22 | final Account account = findById(id); 23 | account.updateMyAccount(dto); 24 | return account; 25 | } 26 | // Account 도메인 클래스 27 | public void updateMyAccount(AccountDto.MyAccountReq dto) { 28 | this.address = dto.getAddress(); 29 | this.fistName = dto.getFistName(); 30 | this.lastName = dto.getLastName(); 31 | } 32 | ``` 33 | Account 도메인 클래스에 updateMyAccount 메소드를 통해서 회원정보업데이트를 진행했습니다. 위의 코드보다 의도가 명확히 드러납니다. 34 | 35 | ```java 36 | public static class MyAccountReq { 37 | private Address address; 38 | private String firstName; 39 | private String lastName; 40 | } 41 | ``` 42 | 위는 MyAccountReq 클래스입니다. 회원 정보 수정에 필요한 값 즉 변경될 값에 대한 명확한 명세가 있어 DTO를 두는 것이 바람직합니다. 43 | 44 | ### 객체의 일관성을 유지하기 어렵다 45 | ```java 46 | public Account updateMyAccount(long id, AccountDto.MyAccountReq dto) { 47 | final Account account = findById(id); 48 | account.setEmail("value"); 49 | return account; 50 | } 51 | ``` 52 | setter 메소드가 있을 때 객체에 언제든지 변경할 수 있게 됩니다. 위처럼 회원 변경 메소드뿐만이 아니라 모든 곳에서 이메일 변경이 가능하게 됩니다. 물론 변경이 불가능 한 항목에 setter 메서드를 두지 않는다는 방법도 있지만 관례로 setter는 모든 멤버필드에 대해서 만들기도 하거니와 실수 조금이라도 덜 할 수 있게 하는 것이 바람직한 구조라고 생각합니다. 53 | 54 | ## Setter를 사용하지 않기 55 | 56 | ### updateMyAccount 57 | 58 | ```java 59 | public Account updateMyAccount(long id, AccountDto.MyAccountReq dto) { 60 | final Account account = findById(id); 61 | account.updateMyAccount(dto); 62 | return account; 63 | } 64 | // Account 도메인 클래스 65 | public void updateMyAccount(AccountDto.MyAccountReq dto) { 66 | this.address = dto.getAddress(); 67 | this.fistName = dto.getFistName(); 68 | this.lastName = dto.getLastName(); 69 | } 70 | ``` 71 | 위의 예제와 같은 예제 코드입니다. findById 메소드를 통해서 영속성을 가진 객체를 가져오고 도메인에 작성된 updateMyAccount를 통해서 업데이트를 진행하고 있습니다. 72 | 73 | **repository.save() 메소드를 사용하지 않았습니다. 다시 말해 메소드들은 객체 그 자신을 통해서 데이터베이스 변경작업을 진행하고, create 메서드에 대해서만 repository.save()를 사용합니다** 74 | 75 | ### create 76 | ```java 77 | // 전체 코드를 보시는 것을 추천드립니다. 78 | public static class SignUpReq { 79 | 80 | private com.cheese.springjpa.Account.model.Email email; 81 | private Address address; 82 | 83 | @Builder 84 | public SignUpReq(Email email, String fistName, String lastName, String password, Address address) { 85 | this.email = email; 86 | this.address = address; 87 | } 88 | 89 | public Account toEntity() { 90 | return Account.builder() 91 | .email(this.email) 92 | .address(this.address) 93 | .build(); 94 | } 95 | } 96 | 97 | public Account create(AccountDto.SignUpReq dto) { 98 | return accountRepository.save(dto.toEntity()); 99 | } 100 | ``` 101 | setter 메소드 없이 create 하는 예제입니다. SignUpReq 클래스는 Request DTO 클래스를 통해서 사용자에게 필요한 값을 입력받고 그 값을 toEntity 메소드를 통해서 Account 객체를 생성하게 됩니다. 이 때 빌더 패턴을 이용해서 객체를 생성했습니다. 도메인 객체를 생성할 때 빌더패턴을 적극 추천해 드립니다. 빌더 패턴에 대해서는 여기서는 별도로 다루지 않겠습니다. 102 | 103 | save 메소드에는 도메인 객체 타입이 들어가야 합니다. 이때 toEntity 메소드를 통해서 해당 객체로 새롭게 도메인 객체가 생성되고 save 메소드를 통해서 데이터베이스에 insert 됩니다. 104 | -------------------------------------------------------------------------------- /doc/step-07.md: -------------------------------------------------------------------------------- 1 | # step-07: Embedded를 적극 활용 2 | Embedded을 사용하면 칼럼들을 자료형으로 규합해서 응집력 및 재사용성을 높여 훨씬 더 객체지향 프로그래밍을 할 수 있게 도울 수 있습니다. Embedded은 다음과 같은 장점들이 있습니다. 3 | 4 | 5 | ## 자료형의 통일 6 | ```java 7 | class Account { 8 | // 단순 String 9 | @email 10 | @Column(name = "email", nullable = false, unique = true) 11 | private String email; 12 | 13 | // Email 자료형 14 | @Embedded 15 | private Email email; 16 | } 17 | 18 | class Email { 19 | @Email 20 | @Column(name = "email", nullable = false, unique = true) 21 | private String value; 22 | } 23 | ``` 24 | 위처럼 단순 String 자료형에서 Email 자료형으로 통일이 됩니다. **자료형이 통일되면 많은 더욱 안전성이 높아지는 효과가 있습니다.** 25 | 26 | ```java 27 | public Account findByEmail(final Email email) { //단순 문자열일 경우 (final String email) 28 | final Account account = accountRepository.findByEmail(email); 29 | if (account == null) throw new AccountNotFoundException(email); 30 | return account; 31 | } 32 | ``` 33 | 이메일로 회원을 조회 할 때 단순 문자열일 경우에는 굳이 이메일 형식을 맞추지 않고도 단순 문자열을 통해서 조회할 수 있습니다. 이것은 편하게 느껴질지는 모르나 안전성에는 좋다고 생각하지 않습니다. 위처럼 정확한 이메일 자료형으로 조회가 가능하게 안전성을 높일 수 있습니다. **위처럼 단순 조회용뿐만이 아니라 Email에 관련된 모든 자료형을 단순 String에서 Email로 변경함으로써 얻을 수 있는 이점은 많습니다.** 34 | 35 | 36 | ## 풍부한 객체 (Rich Obejct) 37 | 38 | ```java 39 | public class Email { 40 | ... 41 | public String getHost() { 42 | int index = value.indexOf("@"); 43 | return value.substring(index); 44 | } 45 | 46 | public String getId() { 47 | int index = value.indexOf("@"); 48 | return value.substring(0, index); 49 | } 50 | } 51 | ``` 52 | 이메일 아이디와 호스트값을 추출해야 하는 기능이 필요해질 경우 기존 String 자료형일 경우에는 해당 로직을 Account 도메인 객체에 추가하든, 유틸성 클래스에 추가하든 해야 합니다. 53 | 54 | 도메인 객체에 추가할 때는 Account 도메인 클래스가 갖는 책임들이 많아집니다. 또 이메일은 어디서든지 사용할 수 있는데 Account 객체에서 이 기능을 정의하는 것은 올바르지 않습니다. 55 | 56 | 유틸성 클래스에 추가하는 것 또한 좋지 않아 보입니다. 일단 유틸성 클래스에 해당 기능이 있는지 알아봐야 하고 기능이 있음에도 불구하고 그것을 모르고 추가하여 중복 코드가 발생하는 일이 너무나도 흔하게 발생합니다. 57 | 58 | 이것을 Email 형으로 빼놓았다면 아래처럼 Email 객체를 사용하는 곳 어디든지 사용할 수 있습니다. 해당 기능은 Email 객체가 해야 하는 일이고 또 그 일을 가장 잘할 수 있는 객체입니다. 또한 코드가 아주 이해하기 쉽게 됩니다. 객체의 기능이 풍부해집니다. 59 | 60 | ```java 61 | email.getHost(); 62 | email.getId(); 63 | ``` 64 | 65 | ## 재사용성 66 | 67 | 가령 해외 송금을 하는 기능이 있다고 가정할 경우 Remittance 클래스는 보내는 금액, 나라, 통화, 받는 금액, 나라, 통화가 68 | 필요합니다. 이처럼 도메인이 복잡해질수록 더 재사용성은 중요합니다. 69 | 70 | ```java 71 | class Remittance { 72 | //자료형이 없는 경우 73 | @Column(name = "send_amount") private double sendAamount; 74 | @Column(name = "send_country") private String sendCountry; 75 | @Column(name = "send_currency") private String sendCurrency; 76 | 77 | @Column(name = "receive_amount") private double receiveAamount; 78 | @Column(name = "receive_country") private String receiveCountry; 79 | @Column(name = "receive_currency") private String receiveCurrency; 80 | 81 | //Money 자료형 82 | private Money snedMoney; 83 | private Money receiveMoney; 84 | } 85 | class Money { 86 | @Column(name = "amount", nullable = false, updatable = false) private double amount; 87 | @Column(name = "country", nullable = false, updatable = false) private Country country; 88 | @Column(name = "currency", nullable = false, updatable = false) private Currency currency; 89 | } 90 | ``` 91 | 위처럼 Money라는 자료형을 두고 금액, 나라, 통화를 두면 도메인을 이해하는데 한결 수월할 뿐만 아니라 수많은 곳에서 재사용 할 수 있습니다. 사용자에게 해당 통화로 금액을 보여줄 때 소숫자리 몇 자리로 보여줄 것인지 등등 핵심 도메인일수록 재사용성을 높여 중복 코드를 제거하고 응집력을 높일 수 있습니다. 92 | 93 | 94 | ## 결론 95 | Embedded의 장점을 계속 이야기했습니다. 자료형을 통일해서 안전성 및 재사용성을 높이고 풍부한 객체를 갖게 함으로써 많은 장점을 얻을 수 있습니다. 이러한 장점들은 객체지향 프로그래밍에 충분히 나와 있는 내용입니다. 제가 하고 싶은 이야기는 **JPA는 결국 객체지향 프로그래밍을 돕는 도구** 라는 이야기입니다. 실제 데이터는 관계형 데이터베이스에 저장됨으로써 객체지향과 패러다임이 일치하지 않는 부분을 JPA는 너무나도 좋게 해결해줍니다. 그러니 JPA가 주는 다양한 어노테이션, 기능들도 좋지만 결국 이것이 궁극적으로 무엇을 위한 것인지 생각해보는 것도 좋다고 생각합니다. -------------------------------------------------------------------------------- /doc/step-08.md: -------------------------------------------------------------------------------- 1 | # step-08: OneToOne 관계 설정 팁 2 | 3 | OneToOne 관계 설정 시에 간단한 팁을 정리하겠습니다. 해당 객체들의 성격은 다음과 같습니다. 4 | 5 | * 주문과 쿠폰 엔티티가 있다. 6 | * 주문 시 쿠폰을 적용해서 할인받을 수 있다. 7 | * 주문과 쿠폰 관계는 1:1 관계 즉 OneToOne 관계이다. 8 | 9 | 주의 깊게 살펴볼 내용은 다음과 같습니다. 10 | 11 | * 외래 키는 어디 테이블에 두는 것이 좋은가? 12 | * 양방향 연관 관계 편의 메소드 13 | * 제약 조건으로 인한 안정성 및 성능 향상 14 | 15 | ## Entity 객체 16 | ```java 17 | public class Coupon { 18 | @Id 19 | @GeneratedValue 20 | private long id; 21 | 22 | @Column(name = "discount_amount") 23 | private double discountAmount; 24 | 25 | @Column(name = "use") 26 | private boolean use; 27 | 28 | @OneToOne() 29 | private Order order; 30 | } 31 | 32 | public class Order { 33 | @Id 34 | @GeneratedValue 35 | private long id; 36 | 37 | @Column(name = "price") 38 | private double price; 39 | 40 | @OneToOne 41 | @JoinColumn() 42 | private Coupon coupon; 43 | } 44 | ``` 45 | 46 | 47 | ## 외래 키는 어디 테이블에 두는 것이 좋은가? 48 | 49 | ```java 50 | // Order가 연관관계의 주인일 경우 51 | @OneToOne 52 | @JoinColumn(name = "coupon_id", referencedColumnName = "id") 53 | private Coupon coupon; 54 | 55 | @OneToOne(mappedBy = "coupon") 56 | private Order order; 57 | 58 | // coupon이 연관관계의 주인일 경우 59 | @OneToOne(mappedBy = "order") 60 | private Coupon coupon; 61 | 62 | @OneToOne 63 | @JoinColumn(name = "order_id", referencedColumnName = "id") 64 | private Order order; 65 | ``` 66 | 67 | 일대다 관계에서는 다 쪽에서 외래 키를 관리 하게 되지만 상대적으로 일대일 관계 설정에는 외래 키를 어느 곳에 두어야 하는지를 생각을 해야 합니다. JPA 상에서는 외래 키가 갖는 쪽이 연관 관계의 주인이 되고 68 | **연관 관계의 주인만이 데이터베이스 연관 관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있기 때문입니다.** 69 | 70 | ## Sample Code 71 | 72 | ```java 73 | // 주문시 1,000 할인 쿠폰을 적용해본 간단한 코드입니다. 74 | public Order order() { 75 | final Order order = Order.builder().price(10_000).build(); // 10,000 상품주문 76 | Coupon coupon = couponService.findById(1); // 1,000 할인 쿠폰 77 | order.applyCoupon(coupon); 78 | return orderRepository.save(order); 79 | } 80 | @Test 81 | public void order_쿠폰할인적용() { 82 | final Order order = orderService.order(); 83 | assertThat(order.getPrice(), is(9_000D)); // 1,000 할인 적용 확인 84 | 85 | final Order findOrder = orderService.findOrder(order.getId()); 86 | System.out.println("couponId : "+ findOrder.getCoupon().getId()); // couponId : 1 (coupon_id 외래 키를 저장 완료) 87 | } 88 | ``` 89 | 90 | 91 | ### Order가 주인일 경우 장점 : INSERT SQL이 한번 실행 92 | 93 | ![](https://i.imgur.com/k6V64ye.png) 94 | 95 | ```sql 96 | // order가 연관 관계의 주인일 경우 SQL 97 | insert into orders (id, coupon_id, price) values (null, ?, ?) 98 | 99 | //coupon이 연관 관계의 주인일 경우 SQL 100 | insert into orders (id, price) values (null, ?) 101 | update coupon set discount_amount=?, order_id=?, use=? where id=? 102 | ``` 103 | order 테이블에 coupon_id 칼럼을 저장하기 때문에 주문 SQL은 한 번만 실행됩니다. 반면에 coupon이 연관 관계의 주인일 경우에는 coupon에 order의 외래 키가 있으니 order INSERT SQL 한 번, coupon 테이블에 order_id 칼럼 업데이트 쿼리 한번 총 2번의 쿼리가 실행됩니다. 104 | 105 | 작은 장점으로는 데이터베이스 칼럼에 coupon_id 항목이 null이 아닌 경우 할인 쿠폰이 적용된 것으로 판단할 수 있습니다. 106 | 107 | ### Order가 주인일 경우 단점 : 연관 관계 변경 시 취약 108 | 기존 요구사항은 주문 한 개에 쿠폰은 한 개만 적용이 가능 했기 때문에 OneToOne 연관 관계를 맺었지만 **하나의 주문에 여러 개의 쿠폰이 적용되는 기능이 추가되었을 때 변경하기 어렵다는 단점이 있습니다.** 109 | 110 | order 테이블에 coupon_id 칼럼을 갖고 있어서 여러 개의 쿠폰을 적용하기 위해서는 coupon 테이블에서 order_id 칼럼을 가진 구조로 변경 해야 합니다. **OneToMany 관계에서는 연관 관계의 주인은 외래 키를 갖는 엔티티가 갖는 것이 바람직합니다.** 비즈니스 로직 변경은 어려운 게 없으나 데이터베이스 칼럼들을 이전 해야 하기 때문에 실제 서비스 중인 프로젝트에는 상당히 골치 아프게 됩니다. 111 | 112 | 장점이 단점이 되고 단점이 장점이 되기 때문에 Coupon 장단점을 정리하지 않았습니다. 113 | 114 | ## 연관 관계의 주인 설정 115 | OneToOne 관계를 맺으면 외래 키를 어디에 둘 것인지, 즉 연관 관계의 주인을 어디에 둘 것인지는 많은 고민이 필요 합니다. 제 개인적인 생각으로는 OneToMany로 변경될 가능성이 있는지를 판단하고 변경이 될 가능성이 있다고 판단되면 Many가 될 엔티티가 관계의 주인이 되는 것이 좋다고 봅니다. 또 애초에 OneToMany를 고려해서 초기 관계 설정을 OneToMany로 가져가는 것도 좋다고 생각합니다. 116 | 117 | 그러니 이 연관 관계가 정말 OneToOne 관계인지 깊은 고민이 필요하고 해당 도메인에 대한 지식도 필요 하다고 생각합니다. 예를 들어 개인 송금 관계에서 입금 <-> 출금 관계를 가질 경우 반드시 하나의 입금 당 하나의 출금을 갖게 되니 이것은 OneToOne 관계로 맺어가도 무리가 없다고 판단됩니다. (물론 아닌 때도 있습니다. 그래서 해당 도메인에 대한 지식이 필요 한다고 생각합니다) 118 | 119 | **주인 설정이라고 하면 뭔가 더 중요한 것이 주인이 되어야 할 거 같다는 생각이 들지만 연관 관계의 주인이라는 것은 외래 키의 위치와 관련해서 정해야 하지 해당 도메인의 중요성과는 상관관계가 없습니다.** 120 | 121 | ## 양방향 연관관계 편의 메소드 122 | 123 | ```java 124 | // Order가 연관관계의 주인일 경우 예제 125 | class Coupon { 126 | ... 127 | // 연관관계 편의 메소드 128 | public void use(final Order order) { 129 | this.order = order; 130 | this.use = true; 131 | } 132 | } 133 | 134 | class Order { 135 | private Coupon coupon; // (1) 136 | ... 137 | // 연관관계 편의 메소드 138 | public void applyCoupon(final Coupon coupon) { 139 | this.coupon = coupon; 140 | coupon.use(this); 141 | price -= coupon.getDiscountAmount(); 142 | } 143 | } 144 | 145 | // 주문 생성시 1,000 할인 쿠폰 적용 146 | public Order order() { 147 | final Order order = Order.builder().price(10_000).build(); // 10,000 상품주문 148 | Coupon coupon = couponService.findById(1); // 1,000 할인 쿠폰 149 | order.applyCoupon(coupon); 150 | return orderRepository.save(order); 151 | } 152 | ``` 153 | 연관 관계의 주인이 해당 참조할 객체를 넣어줘야 데이터베이스의 칼럼에 외래 키가 저장됩니다. 즉 Order가 연관 관계의 주인이면 (1)번 멤버 필드에 Coupon을 넣어줘야 데이터베이스 order 테이블에 coupon_id 칼럼에 저장됩니다. 154 | 155 | 양방향 연관 관계일 경우 위처럼 연관 관계 편의 메소드를 작성하는 것이 좋습니다. 위에서 말했듯이 연관 관계의 주인만이 외래 키를 관리 할 수 있으니 applyCoupon 메소드는 이해하는데 어렵지 않습니다. 156 | 157 | 그렇다면 use 메서드에서에 데이터베이스에 저장하지도 않는 Order를 set을 왜 해주는 걸까요? 158 | 159 | ```java 160 | public void use(final Order order) { 161 | // this.order = order; 해당코드를 주석했을 때 테스트 코드 162 | this.use = true; 163 | } 164 | @Test 165 | public void use_메서드에_order_set_필요이유() { 166 | final Order order = orderService.order(); 167 | assertThat(order.getPrice(), is(9_000D)); // 1,000 할인 적용 확인 168 | final Coupon coupon = order.getCoupon(); 169 | assertThat(coupon.getOrder(), is(notNullValue())); // 해당 검사는 실패한다. 170 | } 171 | ``` 172 | order를 바인딩하는 코드를 주석하고 해당 코드를 돌려보면 실패하게 됩니다. 일반적으로 생각했을 때 order 생성 시 1,000할인 쿠폰을 적용했기 때문에 해당 쿠폰에도 주문 객체가 들어갔을 거로 생각할 수 있습니다. 하지만 위의 주석시킨 코드가 그 기능을 담당했기 때문에 쿠폰 객체의 주문 값은 null인 상태입니다. **즉 순수한 객체까지 고려한 양방향 관계를 고려하는 것이 바람직하고 그것이 안전합니다.** 173 | 174 | ## 제약 조건으로 인한 안정성 및 성능 향상 175 | 176 | ```java 177 | public class Order { 178 | ... 179 | 180 | @OneToOne 181 | @JoinColumn(name = "coupon_id", referencedColumnName = "id", nullable = false) 182 | private Coupon coupon; 183 | } 184 | ``` 185 | 186 | 모든 주문에 할인 쿠폰이 적용된다면 @JoinColumn의 nullable 옵션을 false로 주는 것이 좋습니다. **NOT NULL 제약 조건을 준수해서 안전성이 보장됩니다.** 187 | 188 | ![](https://i.imgur.com/bHfKh8m.png) 189 | * nullable = false 없는 경우, outer join 190 | 191 | ![](https://i.imgur.com/94To549.png) 192 | * nullable = false 선언한 경우, inner join 193 | 194 | **외래 키에 NOT NULL 제약 조건을 설정하면 값이 있는 것을 보장합니다. 따라서 JPA는 이때 내부조인을 195 | 통해서 내부 조인 SQL을 만들어 주고 이것은 외부 조인보다 성능과 최적화에 더 좋습니다.** 196 | 197 | 물론 모든 경우에 적용할 수는 없고 반드시 외래 키가 NOT NULL인 조건에만 사용할 수 있습니다. 예를 들어 쿠폰과 회원 연관 관계가 있을 때 쿠폰은 반드시 회원의 외래 키를 참조하고 있어야 합니다. 이런 경우 유용하게 사용할 수 있습니다. 198 | -------------------------------------------------------------------------------- /doc/step-09.md: -------------------------------------------------------------------------------- 1 | # step-09: OneToMany 관계 설정 팁(2) 2 | 이전에 OneToMany 관계 설정 포스팅이 관계설정의 초점보다는 풍부한 객체 관점 중심으로 다루었습니다. 그러다 보니 OneToMany에 대한 관계에 대한 설명 부분이 부족해서 추가 포스팅을 하게 되었습니다. 3 | 4 | ## 요구사항 5 | * 배송이 있고 배송의 상태를 갖는 배송 로그가 있습니다. 6 | * 배송과 배송 상태는 1:N 관계를 갖는다. 7 | * 배송은 배송 상태를 1개 이상 반드시 갖는다. 8 | 9 | ## Entity 10 | ```java 11 | @Entity 12 | public class Delivery { 13 | @Id @GeneratedValue(strategy = GenerationType.AUTO) 14 | private long id; 15 | 16 | @Embedded 17 | private Address address; 18 | 19 | @OneToMany(mappedBy = "delivery", cascade = CascadeType.PERSIST, orphanRemoval = true, fetch = FetchType.EAGER) 20 | private List logs = new ArrayList<>(); 21 | } 22 | 23 | @Entity 24 | public class DeliveryLog { 25 | @Id @GeneratedValue(strategy = GenerationType.AUTO) 26 | private long id; 27 | 28 | @Enumerated(EnumType.STRING) 29 | @Column(name = "status", nullable = false, updatable = false) 30 | private DeliveryStatus status; 31 | 32 | @ManyToOne 33 | @JoinColumn(name = "delivery_id", nullable = false, updatable = false) 34 | private Delivery delivery; 35 | } 36 | ``` 37 | 38 | ## Delivery 저장 39 | 일대다 관계에서는 다 쪽이 외래 키를 관리하게 됩니다. JPA 상에서는 외래 키가 갖는 쪽이 연관 관계의 주인이 되고 연관 관계의 주인만이 데이터베이스 연관 관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있으므로 DeliveryLog에서 Delivery를 관리하게 됩니다. **하지만 DeliveryLog는 Delivery 상태를 저장하는 로그 성격 이기 때문에 핵심 비즈니스 로직을 Delivery에서 작성하는 것이 바람직합니다.** 40 | 41 | 이럴 때 편의 메소드와 Cascade 타입 PERSIST 이용하면 보다 이러한 문제를 해결 할 수 있습니다. 42 | 43 | ### 편의 메소드 44 | ```java 45 | class Delivery { 46 | public void addLog(DeliveryStatus status) { 47 | this.logs.add(DeliveryLog.builder() 48 | .status(status) 49 | .delivery(this) // this를 통해서 Delivery를 넘겨준다. 50 | .build()); 51 | } 52 | } 53 | 54 | class DeliveryLog { 55 | public DeliveryLog(final DeliveryStatus status, final Delivery delivery) { 56 | this.delivery = delivery; 57 | } 58 | } 59 | 60 | class DeliveryService { 61 | public Delivery create(DeliveryDto.CreationReq dto) { 62 | final Delivery delivery = dto.toEntity(); 63 | delivery.addLog(DeliveryStatus.PENDING); 64 | return deliveryRepository.save(delivery); 65 | } 66 | } 67 | ``` 68 | Delivery가 시작되면 DeliveryLog는 반드시 PENDDING이어야 한다고 가정했을 경우 편의 메소드를 이용해서 두 객체에 모두 필요한 값을 바인딩시켜줍니다. 69 | 70 | ### CaseCade PERSIST 설정 71 | ```sql 72 | // cascade 없는 경우 73 | Hibernate: insert into delivery (id, address1, address2, zip, created_at, update_at) values (null, ?, ?, ?, ?, ?) 74 | 75 | // cascade PERSIST 설정 했을 경우 76 | Hibernate: insert into delivery (id, address1, address2, zip, created_at, update_at) values (null, ?, ?, ?, ?, ?) 77 | Hibernate: insert into delivery_log (id, created_at, update_at, delivery_id, status) values (null, ?, ?, ?, ?) 78 | ``` 79 | CaseCade PERSIST을 통해서 Delivery 엔티티에서 DeliveryLog를 생성할수 있게 설정합니다. CaseCade PERSIST가 없을 때 실제 객체에는 저장되지만, 영속성 있는 데이터베이스에 저장에 필요한 insert query가 동작하지 않습니다. 80 | 81 | **JPA를 잘활용하면 도메인의 의도가 분명하게 들어나도록 개발할 수 있다는 것을 강조드리고 싶습니다.** 82 | 83 | ## 고아 객체 (orphanRemoval) 84 | JPA는 부모 엔티티와 연관 관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이것을 고아 객체 제거라 합니다. 이 기능을 사용해서 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제 돼서 개발의 편리함이 있습니다. 85 | 86 | ### DeliveryLog 삭제 87 | 88 | ```java 89 | public Delivery removeLogs(long id) { 90 | final Delivery delivery = findById(id); 91 | delivery.getLogs().clear(); // DeloveryLog 전체 삭제 92 | return delivery; // 실제 DeloveryLog 삭제 여부를 확인하기 위해 리턴 93 | } 94 | ``` 95 | ```sql 96 | // delete SQL 97 | Hibernate: delete from delivery_log where id=? 98 | ``` 99 | Delivery 객체를 통해서 DeliveryLog를 위처럼 직관적으로 삭제 할 수 있습니다. 이 처럼 직관적으로 그 의도가 드러나는 장점이 있다고 생각합니다. 100 | 101 | ### Delivery 삭제 102 | 103 | ```java 104 | public void remove(long id){ 105 | deliveryRepository.delete(id); 106 | } 107 | ``` 108 | ```sql 109 | // delete SQL 110 | Hibernate: delete from delivery_log where id=? 111 | Hibernate: delete from delivery where id=? 112 | ``` 113 | delivery, deliverylog 참조 관계를 맺고 있어 Delivery만 삭제할 수 없습니다. delete SQL을 보시다시피 delivery_log 부터 제거 이후 delivery를 제거하는 것을 알 수 있습니다. 이처럼 orphanRemoval 속성으로 더욱 쉽게 삭제 할 수 있습니다. 114 | 115 | ### orphanRemoval 설정이 없는 경우 116 | DeliveryLog 삭제 같은 경우에는 실제 객체에서는 clear() 메서드로 DeliveryLog가 삭제된 것처럼 보이지만 영속성 있는 데이터를 삭제하는 것은 아니기에 해당 Delivery를 조회하면 DeliveryLog가 그대로 조회됩니다. 실수하기 좋은 부분이기에 반드시 삭제하고 조회까지 해서 데이터베이스까지 확인하시는 것을 권장해 드립니다. 117 | 118 | ![](https://i.imgur.com/bPhMX9e.png) 119 | 120 | ``` 121 | // erorr log 122 | o.h.engine.jdbc.spi.SqlExceptionHelper : Referential integrity constraint violation: "FKFS49KM0EA809MTH3OQ4S6810H: PUBLIC.DELIVERY_LOG FOREIGN KEY(DELIVERY_ID) REFERENCES PUBLIC.DELIVERY(ID) (1)"; SQL statement: 123 | ``` 124 | 위에서 언급했듯이 delivery를 삭제하려면 참조 하는 deliverylog 먼저 삭제를 진행 해야 합니다. orphanRemoval 설정이 없는 경우 그 작업을 선행하지 않으니 위 같은 에러가 발생하게 됩니다. -------------------------------------------------------------------------------- /doc/step-10.md: -------------------------------------------------------------------------------- 1 | # step-10: Properties 설정값 가져오기 2 | 3 | Properties 설정값을 가져오는 다양한 방법들이 있습니다. 방법이 많다 보니 좋지 않은 패턴으로 사용하는 예도 흔하게 발생합니다. 안티 패턴을 소개하고 이것이 왜 안 좋은지 간단하게 소개하고 제가 생각하는 좋은 패턴도 소개해드리겠습니다. 4 | 5 | 6 | ## properties 7 | ```yml 8 | user: 9 | email: "yun@test.com" 10 | nickname: "yun" 11 | age: 28 12 | auth: false 13 | amount: 101 14 | ``` 15 | properties 설정은 위와 같습니다. 참고로 .yml 설정 파일입니다. 16 | 17 | ## 안티패턴 : Environment 18 | 19 | ```java 20 | public class AntiSamplePropertiesRunner implements ApplicationRunner { 21 | private final Environment env; 22 | 23 | @Override 24 | public void run(ApplicationArguments args) { 25 | final String email = env.getProperty("user.email"); 26 | final String nickname = env.getProperty("user.nickname"); 27 | final int age = Integer.valueOf(env.getProperty("user.age")); 28 | final boolean auth = Boolean.valueOf(env.getProperty("user.auth")); 29 | final int amount = Integer.valueOf(env.getProperty("user.amount")); 30 | 31 | log.info("=========ANTI========="); 32 | log.info(email); // "yun@test.com" 33 | log.info(nickname); // yun 34 | log.info(String.valueOf(age)); // 27 35 | log.info(String.valueOf(auth)); // true 36 | log.info(String.valueOf(amount)); // 100 37 | log.info("=========ANTI========="); 38 | } 39 | } 40 | ``` 41 | 일반적으로 가장 쉬운 Environment를 활용한 방법입니다. 많은 것들 생각하지 않고 properties에 정의된 것을 key 값으로 찾아옵니다. 42 | 43 | 위의 Environment 이용해서 properties에서 설정을 가져오는 것은 편하지만 단점들이 있습니다. 44 | 45 | ### 정확한 자료형 확인의 어려움 46 | key 값으로 어느 정도 유추할 수 있지만 어디까지나 유추이지 정확한 자료형을 확인하기 위해서는 properties에서 value 값을 기반으로 자료형을 확인해야 합니다. 또 amount 값이 100 이기 때문에 int 타입으로 바인딩시켰지만 amount 값은 소수로 값이 변경될 수도 있습니다. 이 또한 값을 통해서 자료형을 유추했기 때문에 발생한다고 생각합니다. 47 | 48 | ### 변경시 관리의 어려움 49 | email의 키값이 email-address로 변경됐을시 getProperty() 메서드를 통해서 바인딩 시킨 부분들은 모두 email-address로 변경해야 합니다. 변경하는 것도 문제지만 만약 1개의 메소드라도 실수로 놓쳤을 경우 에러 발생 시점에 runtime으로 넘어가게 되고 해당 에러가 NullPointException이 발생하기 전까지는 확인하기 어렵습니다. 50 | 51 | ## 추천 패턴 : ConfigurationProperties 52 | 53 | ```java 54 | @Configuration 55 | @ConfigurationProperties(prefix = "user") 56 | @Validated 57 | public class SampleProperties { 58 | @Email 59 | private String email; 60 | @NotEmpty 61 | private String nickname; 62 | private int age; 63 | private boolean auth; 64 | private double amount; 65 | 66 | // getter, setter 67 | } 68 | 69 | public class SamplePropertiesRunner implements ApplicationRunner { 70 | private final SampleProperties properties; 71 | @Override 72 | public void run(ApplicationArguments args) { 73 | final String email = properties.getEmail(); 74 | final String nickname = properties.getNickname(); 75 | final int age = properties.getAge(); 76 | final boolean auth = properties.isAuth(); 77 | final double amount = properties.getAmount(); 78 | 79 | log.info("=================="); 80 | log.info(email); // yun@test.com 81 | log.info(nickname); // yun 82 | log.info(String.valueOf(age)); // 27 83 | log.info(String.valueOf(auth)); // true 84 | log.info(String.valueOf(amount)); // 100.0 85 | log.info("=================="); 86 | } 87 | } 88 | ``` 89 | 아주 간단하고 명확한 해결 방법은 ConfigurationProperties를 이용해서 POJO 객체를 두는 것입니다. 장점들은 다음과 같습니다. 90 | 91 | ### Validation 92 | 93 | ```yml 94 | user: 95 | email: "yun@" // 이메일 형식 올바르지 않음 -> @Email 96 | nickname: "" // 필수 값 -> @NotEmpty 97 | ``` 98 | JSR-303 기반으로 Validate 검사를 할 수 있습니다. 위 코드 처럼 `@Validated`, `@Email` 어노테이션을 이용하면 쉽게 유효성 검사를 할 수 있습니다. 99 | 100 | ```bash 101 | 102 | Binding to target com.cheese.springjpa.properties.SampleProperties$$EnhancerBySpringCGLIB$$68016904@3cc27db9 failed: 103 | 104 | Property: user.email 105 | Value: yun@ 106 | Reason: 이메일 주소가 유효하지 않습니다. 107 | 108 | Binding to target com.cheese.springjpa.properties.SampleProperties$$EnhancerBySpringCGLIB$$d2899f85@3ca58cc8 failed: 109 | 110 | Property: user.nickname 111 | Value: 112 | Reason: 반드시 값이 존재하고 길이 혹은 크기가 0보다 커야 합니다. 113 | ``` 114 | 115 | **위와 같이 컴파일 과정 중에 잡아주고 에러 메시지도 상당히 구체적입니다.** 116 | 117 | ### 빈으로 등록 해서 재사용성이 높음 118 | 119 | ```java 120 | public class SampleProperties { 121 | @Email 122 | private String email; 123 | @NotEmpty 124 | private String nickname; 125 | private int age; 126 | private boolean auth; 127 | private double amount; 128 | 129 | // getter, setter 130 | // properties 사용할 떄는 SampleProperties 객체를 사용함, 데이터의 응집력, 캡슐화가 높아짐 131 | } 132 | ``` 133 | 134 | SamplePropertiesRunner 클래스를 보시면 SampleProperties를 의존성 주입을 받아서 다른 빈에서 재사용성이 높습니다. 단순히 재사용성이 높은 것이 아니라 user의 응집력이 높아집니다. 135 | 136 | 개별적으로 user에 대한 데이터 email, nickname, age...를 나열하는 것은 응집력을 심각하게 떨어트린다고 생각합니다. user가 가지고 있는 정보들은 또 무엇인지 확인 하기 어렵고 정확한 타입을 유추하기도 어렵습니다. 이로 인해서 캡슐화의 심각한 저하로 이어 집니다.. 137 | 138 | ### 그밖에 장점들 139 | Relaxed Binding으로 properties 키값에 유연하게 지정할 수 있습니다. 140 | 141 | SampleProperties에 firstName 추가되었을 때 바인딩시킬 properties 키값을 first-name, FIRSTNAME, firstName 등을 사용해도 바인딩이 됩니다. 장점이긴 하지만 반드시 하나의 네이밍을 정하고 통일하는 게 바람직하다고 생각합니다. 142 | 143 | 144 | 145 | ## 결론 146 | **위에서 설명한 부분을 properties의 한에서만 생각하지 않고 객체를 바라볼 때 데이터의 응집력, 캡슐화를 높이는 방법을 고민하는 것이 중요하다고 생각합니다.** 147 | 148 | ## 참고 자료 149 | * [Spring Boot Reference Guide](https://docs.spring.io/spring-boot/docs/current-SNAPSHOT/reference/htmlsingle/) -------------------------------------------------------------------------------- /doc/step-11.md: -------------------------------------------------------------------------------- 1 | # step-11: Properties environment 설정하기 2 | 3 | properties.yml 설정 파일을 이용해서 environment를 편리하게 설정하는 방법을 소개해드리겠습니다. 설정 정보는 애플리케이션 코드와 분리돼서 관리되고 각 환경에 따라 달라지(DB 정보, 외부 서비스 정보 등등)는 정보들은 각 properties 파일에서 관리되는 것이 좋습니다. 4 | 5 | 6 | | environment | 설명 | 파일명 | 7 | | ----------- | ------- | --------------------- | 8 | | local | 로컬 개발환경 | application-local.yml | 9 | | dev | 개발환경 | application-dev.yml | 10 | | prod | 운영 | application-prod.yml | 11 | 12 | 13 | 위 처럼 환경이 분리되었을 경우를 기준으로 설명드리겠습니다. 14 | 15 | 16 | ## application.yml 17 | ```yml 18 | server: 19 | port: 8080 20 | ``` 21 | * 모든 환경에서 공통으로 사용할 정보들을 작성합니다. 22 | * 모든 환경에서 사용할 것을 공통으로 사용하기 때문에 코드의 중복과 변경에 이점이 있습니다. 23 | * 본 예제에서는 port만 공통으로 설정했습니다. 24 | 25 | ## application-{env}.yml 26 | 27 | ```yml 28 | user: 29 | email: "yun@test" 30 | nickname: "nickname" 31 | age: 28 32 | auth: false 33 | amount: 101 34 | 35 | spring: 36 | jpa: 37 | database: h2 38 | hibernate: 39 | ddl-auto: create-drop 40 | show-sql: true 41 | datasource: 42 | data: classpath:init.sql # 시작할때 실행시킬 script 43 | 44 | jackson: 45 | serialization: 46 | WRITE_DATES_AS_TIMESTAMPS: false 47 | 48 | logging: 49 | level: 50 | ROOT: info 51 | ``` 52 | * 각 개발환경에 맞는 properties 설정을 정의합니다. 53 | * 대표적으로 데이터베이스 정보, 외부 설정 정보 등이 있습니다. 54 | * `application.yml` 에서 정의한 `server.port` 8080 값이 자동으로 설정됩니다. 55 | 56 | ## env 설정 방법 57 | 58 | ### application.yml에서 설정하는 방법 59 | ```yml 60 | spring: 61 | profiles: 62 | active: local 63 | 64 | server: 65 | port: 8080 66 | ``` 67 | * `profiles.active` 속성에 원하는 정보 env를 작성합니다. 68 | 69 | ### IntelliJ에서 설정 하는 방법 70 | ![](../images/intellij-properties.png) 71 | 72 | IntelliJ에서는 외부에서 넘겨 받는 인자로 다양한 환경 설정을 제공해줍니다. 가장 대표적인 방법으로 VM options, Active profiles 설정이 있습니다. 아래의 방법중 하나를 선택하면 됩니다. 73 | 74 | * VM options에 `-Dspring.profiles.active={env}` 추가합니다. 75 | * Active profiles: `{env}` 76 | 77 | 78 | ## 우선순위 79 | 외부 환경 설정에 대한 우선순위는 [Spring-Boot Document](https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-external-config.html#boot-features-external-config)에 표시되어 있습니다. 실제 배포시에는 우선순위를 반드시 고려해야합니다. 80 | 81 | ## env 구동 82 | ```yml 83 | spring: 84 | profiles: 85 | active: local 86 | 87 | server: 88 | port: 8080 89 | ``` 90 | `application.yml`으로 설정해서 스프링을 구동시켜보겠습니다. 91 | 92 | ![](../images/spring-profile.png) 93 | 94 | `application.yml`에서 설정한 local 환경설정이 동작하는 것을 확인할 수 있습니다. 95 | -------------------------------------------------------------------------------- /doc/step-13.md: -------------------------------------------------------------------------------- 1 | # step-13: Query Dsl이용한 페이징 API 만들기 2 | 3 | 4 | [step-12: 페이징 API 만들기](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-12.md) 에서 JPA와 `Pageable`를 이용해서 간단한 페이징 API를 만들었습니다. 이번 포스팅에서는 Query Dsl 동적 쿼리를 이용해서 검색 페이징 API를 만들어 보겠습니다. 5 | 6 | ## 기초 작업 7 | 8 | Maven을 기준으로 설명드리겠습니다. 아래의 코드를 `pom.xml`에 추가하고 `mvn compile`을 진행합니다. 9 | ```xml 10 | 11 | com.querydsl 12 | querydsl-apt 13 | 14 | 15 | 16 | com.querydsl 17 | querydsl-jpa 18 | 19 | 20 | 21 | com.mysema.maven 22 | apt-maven-plugin 23 | 1.1.3 24 | 25 | 26 | 27 | process 28 | 29 | 30 | target/generated-sources/java 31 | com.querydsl.apt.jpa.JPAAnnotationProcessor 32 | 33 | 34 | 35 | 36 | ``` 37 | 38 | 39 | ![](/images/querydsl-path.png) 40 | 41 | complie이 성공적으로 완료되면 `target/generated-sources/java` 디렉토리에 `QXXX` 클래스 파일 생성되는 것을 확인할 수 있습니다. 42 | 43 | 44 | 45 | 46 | ## Controller 47 | ```java 48 | @RestController 49 | @RequestMapping("accounts") 50 | public class AccountController { 51 | 52 | @GetMapping 53 | public Page getAccounts( 54 | @RequestParam(name = "type") final AccountSearchType type, 55 | @RequestParam(name = "value", required = false) final String value, 56 | final PageRequest pageRequest 57 | ) { 58 | return accountSearchService.search(type, value, pageRequest.of()).map(AccountDto.Res::new); 59 | } 60 | } 61 | 62 | public enum AccountSearchType { 63 | EMAIL, 64 | NAME, 65 | ALL 66 | } 67 | ``` 68 | * type은 `AccountSearchType` enum으로 검색 페이징을 위한 type을 의미합니다. 본 예제에서는 이메일, 이름, 전체 페이징 기능을 제공합니다. 69 | * `value`는 type에 대한 value를 의미합니다. 이메일 검색시에는 value에 검색하고자하는 값을 지정합니다. 70 | * `PageRequest`는 [step-12: 페이징 API 만들기](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-12.md)에서 사용한 객체를 그대로 사용 하면 됩니다. 71 | 72 | 73 | 검색을 위한 type은 `String` 객체로 관리하는 것보다 `enum`으로 관리하는 것이 훨씬 효율적이라고 생각합니다. 만약 위에서 지정한 type 이외의 값을 요청할 경우 예외처리, `Service`영역에서 추가적인 처리 등 다양한 관점에서 `enum`이 훨씬 효율적입니다. 74 | 75 | ## Service 76 | ```java 77 | @Service 78 | @Transactional(readOnly = true) 79 | public class AccountSearchService extends QuerydslRepositorySupport { 80 | 81 | public AccountSearchService() { 82 | super(Account.class); 83 | } 84 | 85 | public Page search(final AccountSearchType type, final String value, final Pageable pageable) { 86 | final QAccount account = QAccount.account; 87 | final JPQLQuery query; 88 | 89 | switch (type) { 90 | case EMAIL: 91 | query = from(account) 92 | .where(account.email.value.likeIgnoreCase(value + "%")); 93 | break; 94 | case NAME: 95 | query = from(account) 96 | .where(account.firstName.likeIgnoreCase(value + "%") 97 | .or(account.lastName.likeIgnoreCase(value + "%"))); 98 | break; 99 | case ALL: 100 | query = from(account).fetchAll(); 101 | break; 102 | default: 103 | throw new IllegalArgumentException(); 104 | } 105 | final List accounts = getQuerydsl().applyPagination(pageable, query).fetch(); 106 | return new PageImpl<>(accounts, pageable, query.fetchCount()); 107 | } 108 | 109 | } 110 | ``` 111 | `QuerydslRepositorySupport`를 이용하면 동적 쿼리를 쉽게 만들수 있습니다. 객체 기반으로 쿼리를 만드는 것이라서 타입 세이프의 강점을 그대로 가질 수 있습니다. `QuerydslRepositorySupport` 추상 클래스를 상속 받고 기본 생성자를 통해서 조회 대상 엔티티 클래스를 지정합니다. 112 | 113 | 114 | `search(...)` 메서드는 컨트롤러에서 넘겨 받은 `type`, `value`, `pageable`를 기반으로 동적 쿼리를 만드는 작업을 진행합니다. 115 | 116 | QueryDsl에서 생성한 `QAccount` 객체를 기반으로 동적 쿼리 작업을 진행합니다. `switch`문을 통해서 각 타입에 맞는 쿼리문을 작성하고 있습니다. 우리가 일반적으로 작성하는 쿼리와 크게 다르지 않아 해당 코드는 이해하기 어렵지 않습니다. 이것이 QueryDsl이 갖는 장점이라고 생각합니다. 117 | 118 | `NAME` 타입인 경우에는 `firstName`, `lastName`에 대한 like 검색을 진행합니다. `ALL`같은 경우에는 이전에 작성했던 전체 페이징과 동일합니다. 119 | 120 | ## 요청 121 | ![](/images/search-paging.png) 122 | 123 | `NAME` 타입으로 `yun`으로 요청을 합니다. `firstName` or `lastName`에 `yun`이 들어가 있는 계정을 검색 합니다. 124 | 125 | ## 응답 126 | ```json 127 | { 128 | "content": [ 129 | { 130 | "email": { 131 | "value": "test001@test.com" 132 | }, 133 | "password": { 134 | "value": "$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6", 135 | "expirationDate": "+20120-01-20T00:00:00", 136 | "failedCount": 0, 137 | "ttl": 1209604, 138 | "expiration": false 139 | }, 140 | "fistName": "yun", 141 | "lastName": "jun", 142 | "address": { 143 | "address1": "address1", 144 | "address2": "address2", 145 | "zip": "002" 146 | } 147 | }, 148 | { 149 | "email": { 150 | "value": "test008@test.com" 151 | }, 152 | "password": { 153 | "value": "$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6", 154 | "expirationDate": "+20120-01-20T00:07:00", 155 | "failedCount": 0, 156 | "ttl": 1209604, 157 | "expiration": false 158 | }, 159 | "fistName": "yun", 160 | "lastName": "builder", 161 | "address": { 162 | "address1": "address1", 163 | "address2": "address2", 164 | "zip": "002" 165 | } 166 | } 167 | ], 168 | "pageable": { 169 | "sort": { 170 | "sorted": true, 171 | "unsorted": false, 172 | "empty": false 173 | }, 174 | "offset": 0, 175 | "pageSize": 4, 176 | "pageNumber": 0, 177 | "paged": true, 178 | "unpaged": false 179 | }, 180 | "totalPages": 1, 181 | "totalElements": 2, 182 | "last": true, 183 | "size": 4, 184 | "number": 0, 185 | "sort": { 186 | "sorted": true, 187 | "unsorted": false, 188 | "empty": false 189 | }, 190 | "numberOfElements": 2, 191 | "first": true, 192 | "empty": false 193 | } 194 | ``` 195 | 196 | ## SQL 197 | 198 | ```sql 199 | select 200 | account0_.id as id1_0_, 201 | account0_.address1 as address2_0_, 202 | account0_.address2 as address3_0_, 203 | account0_.zip as zip4_0_, 204 | account0_.created_at as created_5_0_, 205 | account0_.email as email6_0_, 206 | account0_.first_name as first_na7_0_, 207 | account0_.last_name as last_nam8_0_, 208 | account0_.password_expiration_date as password9_0_, 209 | account0_.password_failed_count as passwor10_0_, 210 | account0_.password_ttl as passwor11_0_, 211 | account0_.password as passwor12_0_, 212 | account0_.update_at as update_13_0_ 213 | from 214 | account account0_ 215 | where 216 | lower(account0_.first_name) like ? 217 | or lower(account0_.last_name) like ? 218 | order by 219 | account0_.created_at asc limit ? 220 | ``` 221 | 222 | 리스트 조회에 대한 쿼리는 반드시 해당 쿼리가 어떻게 출력되는지 반드시 확인해야 합니다. 해당 객체는 연관관계 설정이 되어 있지 않아 N + 1문제가 발생할 여지가 없지만, 실무에서는 많은 객체와의 관계를 맺기 때문에 반드시 쿼리가 어떻게 동작하는지 확인해야 합니다. 223 | 224 | 225 | 226 | -------------------------------------------------------------------------------- /doc/step-14.md: -------------------------------------------------------------------------------- 1 | # step-14: JUnit5 적용하기 2 | 3 | JUnit5는 다양한 어노테이션들이 추가되었습니다. 그중에 Junit5를 도입할 만큼 매력 있는 어노테이션 `@DisplayName` 입니다. 4 | 5 | 단순한 테스트 이외에는 테스트 코드 네이밍으로 테스트하고자 하는 의미를 전달하기가 매우 어렵습니다. 이때 아주 유용하게 사용할 수 있는 것이 `@DisplayName` 입니다. 6 | 7 | ![](/images/junit5-display-name.png) 8 | 9 | 10 | 위 그림처럼 `@DisplayName(....)` 어노테이션으로 코드에 대한 설명을 문자열로 대체할 수 있습니다. 이 대체된 문자열은 실제 테스트 케이스 이름으로 표시됩니다. 11 | 12 | ## 의존성 추가 13 | Spring Boot2의 테스트코드 의존성은 JUnit4를 기본으로 가져오기 때문에 `spring-boot-starter-test` 의존성 이외에도 추가적인 작업이 필요합니다. 14 | 15 | ```xml 16 | 17 | org.springframework.boot 18 | spring-boot-starter-test 19 | test 20 | 21 | 22 | 23 | junit 24 | junit 25 | 26 | 27 | 28 | 29 | 30 | 31 | org.junit.jupiter 32 | junit-jupiter-api 33 | 5.3.2 34 | test 35 | 36 | 37 | 38 | org.mockito 39 | mockito-core 40 | test 41 | 42 | 43 | 44 | org.mockito 45 | mockito-junit-jupiter 46 | test 47 | 48 | 49 | 50 | org.junit.platform 51 | junit-platform-runner 52 | 1.2.0 53 | test 54 | 55 | 56 | 57 | org.junit.vintage 58 | junit-vintage-engine 59 | 5.2.0 60 | test 61 | 62 | ``` 63 | 64 | ## 테스트 코드 65 | 66 | ```java 67 | import org.junit.jupiter.api.DisplayName; 68 | import org.junit.jupiter.api.Test; 69 | import org.junit.jupiter.api.extension.ExtendWith; 70 | import org.junit.platform.runner.JUnitPlatform; 71 | import org.junit.runner.RunWith; 72 | import org.mockito.InjectMocks; 73 | import org.mockito.Mock; 74 | import org.mockito.junit.jupiter.MockitoExtension; 75 | 76 | import java.util.Optional; 77 | 78 | import static org.hamcrest.CoreMatchers.is; 79 | import static org.junit.Assert.assertThat; 80 | import static org.mockito.ArgumentMatchers.anyLong; 81 | import static org.mockito.BDDMockito.given; 82 | import static org.mockito.Mockito.atLeastOnce; 83 | import static org.mockito.Mockito.verify; 84 | 85 | 86 | @ExtendWith(MockitoExtension.class) 87 | public class AccountServiceJUnit5Test { 88 | 89 | @InjectMocks 90 | private AccountService accountService; 91 | 92 | @Mock 93 | private AccountRepository accountRepository; 94 | 95 | @Test 96 | @DisplayName("findById_존재하는경우_회원리턴") 97 | public void findBy_not_existed_test() { 98 | //given 99 | final AccountDto.SignUpReq dto = buildSignUpReq(); 100 | given(accountRepository.findById(anyLong())).willReturn(Optional.of(dto.toEntity())); 101 | 102 | //when 103 | final Account account = accountService.findById(anyLong()); 104 | 105 | //then 106 | verify(accountRepository, atLeastOnce()).findById(anyLong()); 107 | assertThatEqual(dto, account); 108 | } 109 | } 110 | ``` 111 | 필요한 패키지의 경로가 중요하기 때문에 필요한 `import`을 추가했습니다. 아직 Spring Boot2에서 기본으로 가져온 의존성이 아니기 때문에 복잡한 부분이 있습니다. Prod 코드에는 Spring Boot2에서 JUnit5를 기본으로 택했을 때 변경하는 것이 더 안전하고 효율적이라고 생각합니다. 112 | -------------------------------------------------------------------------------- /doc/step-15.md: -------------------------------------------------------------------------------- 1 | # step-15: Querydsl를 이용해서 Repository 확장하기 (1) 2 | 3 | 4 | ## Repository Code 5 | ```java 6 | public interface AccountRepository extends JpaRepository, AccountCustomRepository { 7 | 8 | Account findByEmail(Email email); 9 | 10 | boolean existsByEmail(Email email); 11 | 12 | List findDistinctFirstBy... 13 | 14 | @Query("select *from....") 15 | List findXXX(); 16 | } 17 | ``` 18 | 19 | JpaRepository를 이용해서 복잡한 쿼리는 작성하기가 어려운점이 있습니다. `findByEmail`, `existsByEmail` 같은 유니크한 값을 조회하는 것들은 쿼리 메서드로 표현하는 것이 가독성 및 생산성에 좋습니다. 20 | 21 | **하지만 쿼리가 복잡해지면 쿼리 메서드로 표현하기도 어렵습니다. `@Query` 어노테이션을 이용해서 JPQL을 작성하는 것도 방법이지만 type safe 하지 않아 유지 보수하기 어려운 단점이 있습니다.** 22 | 23 | 이러한 단점은 `Querydsl`를 통해서 해결할 수 있지만 조회용 DAO 클래스 들이 남발되어 다양한 DAO를 DI 받아 비즈니스 로직을 구현하게 되는 현상이 발생하게 됩니다. 24 | 25 | 이러한 문제를 상속 관계를 통해 `XXXRepository` 객체를 통해서 DAO를 접근할 수 있는 패턴을 포스팅 하려 합니다. 26 | 27 | ![](/images/AccountRepository.png) 28 | 29 | 클래스 다이어그램을 보면 `AccountRepository`는 `AccountCustomRepository`, `JpaRepository`를 구현하고 있습니다. 30 | 31 | `AccountRepository`는 `JpaRepository`를 구현하고 있으므로 `findById`, `save` 등의 메서드를 정의하지 않고도 사용 가능했듯이 `AccountCustomRepository`에 있는 메서드도 `AccountRepository`에서 그대로 사용 가능합니다. 32 | 33 | 즉 우리는 `AccountCustomRepositoryImpl`에게 복잡한 쿼리는 구현을 시키고 `AccountRepository` 통해서 마치 `JpaRepository`를 사용하는 것처럼 편리하게 사용할 수 있습니다. 34 | 35 | 36 | ## Code 37 | 38 | ```java 39 | public interface AccountRepository extends JpaRepository, AccountCustomRepository { 40 | Account findByEmail(Email email); 41 | boolean existsByEmail(Email email); 42 | } 43 | 44 | public interface AccountCustomRepository { 45 | List findRecentlyRegistered(int limit); 46 | } 47 | 48 | @Transactional(readOnly = true) 49 | public class AccountCustomRepositoryImpl extends QuerydslRepositorySupport implements AccountCustomRepository { 50 | 51 | public AccountCustomRepositoryImpl() { 52 | super(Account.class); 53 | } 54 | 55 | @Override 56 | // 최근 가입한 limit 갯수 만큼 유저 리스트를 가져온다 57 | public List findRecentlyRegistered(int limit) { 58 | final QAccount account = QAccount.account; 59 | return from(account) 60 | .limit(limit) 61 | .orderBy(account.createdAt.desc()) 62 | .fetch(); 63 | } 64 | } 65 | ``` 66 | * `AccountCustomRepository` 인터페이스를 생성합니다. 67 | * `AccountRepository` 인터페이스에 방금 생성한 `AccountCustomRepository` 인터페이스를 `extends` 합니다. 68 | * `AccountCustomRepositoryImpl`는 실제 Querydsl를 이용해서 `AccountCustomRepository`의 세부 구현을 진행합니다. 69 | 70 | **커스텀 Repository를 만들 때 중요한 것은 `Impl` 네이밍을 지켜야합니다.** 자세한 것은 71 | [Spring Data JPA - Reference Documentation](https://docs.spring.io/spring-data/jpa/docs/2.1.3.RELEASE/reference/html/#repositories.custom-implementations)을 참조해주세요 72 | 73 | ## Test Code 74 | 75 | ```java 76 | @DataJpaTest 77 | @RunWith(SpringRunner.class) 78 | public class AccountRepositoryTest { 79 | 80 | @Autowired 81 | private AccountRepository accountRepository; 82 | 83 | @Test 84 | public void findByEmail_test() { 85 | final String email = "test001@test.com"; 86 | final Account account = accountRepository.findByEmail(Email.of(email)); 87 | assertThat(account.getEmail().getValue()).isEqualTo(email); 88 | } 89 | 90 | @Test 91 | public void isExistedEmail_test() { 92 | final String email = "test001@test.com"; 93 | final boolean existsByEmail = accountRepository.existsByEmail(Email.of(email)); 94 | assertThat(existsByEmail).isTrue(); 95 | } 96 | 97 | @Test 98 | public void findRecentlyRegistered_test() { 99 | final List accounts = accountRepository.findRecentlyRegistered(10); 100 | assertThat(accounts.size()).isLessThan(11); 101 | } 102 | } 103 | ``` 104 | `findByEmail_test`, `isExistedEmail_test` 테스트는 `AccountRepository`에 작성된 쿼리메서드 테스트입니다. 105 | 106 | 중요한 부분은 `findRecentlyRegistered_test` 으로 `AccountCustomRepository`에서 정의된 메서드이지만 `accountRepository`를 이용해서 호출하고 있습니다. 107 | 108 | 즉 `accountRepository` 객체를 통해서 복잡한 쿼리의 세부 구현체 객체를 구체적으로 알 필요 없이 사용할 수 있습니다. **이는 의존성을 줄일 수 있는 좋은 구조라고 생각합니다.** 109 | 110 | ## 결론 111 | `Repository`에서 복잡한 조회 쿼리를 작성하는 것은 유지 보수 측면에서 좋지 않습니다. 쿼리 메서드로 표현이 어려우며 `@Qeury` 어노테이션을 통해서 작성된 쿼리는 type safe하지 않은 단점이 있습니다. 이것을 **QueryDsl으로 해결하고 다형성을 통해서 복잡한 쿼리의 세부 구현은 감추고 `Repository`를 통해서 사용하도록 하는 것이 핵심입니다.** -------------------------------------------------------------------------------- /doc/step-16.md: -------------------------------------------------------------------------------- 1 | # step-16: Querydsl를 이용해서 Repository 확장하기 (2) 2 | 3 | JpaRepository의 쿼리 메서드를 통해서 간단한 쿼리들을 아래 예제 처럼 쉽게 만들수 있습니다. 4 | 5 | ```java 6 | public interface AccountRepository extends JpaRepository { 7 | 8 | boolean existsByEmail(Email email); 9 | 10 | boolean exsistByxxx(...) 11 | 12 | long countByEmail(Email email); 13 | 14 | long countByxxx(...) 15 | } 16 | ``` 17 | 유사한 쿼리가 필요해지면 쿼리 메서드를 지속적으로 추가해야 하는 단점이 있습니다. 이런 경우에 `QuerydslPredicateExecutor`를 사용하면 매우 효과적입니다. 18 | 19 | 20 | 21 | ## QuerydslPredicateExecutor 22 | 23 | ```java 24 | public interface QuerydslPredicateExecutor { 25 | 26 | .... 27 | 28 | long count(Predicate predicate); 29 | 30 | boolean exists(Predicate predicate); 31 | 32 | } 33 | ``` 34 | 35 | `QuerydslPredicateExecutor` 코드의 일부입니다. `Predicate`를 매개변수로 받고 있기 때문에 Predicate를 통해서 새로운 쿼리를 만들수 있습니다. 36 | 37 | ## AccountRepository 적용하기 38 | 39 | ```java 40 | public interface AccountRepository extends JpaRepository, AccountSupportRepository, 41 | QuerydslPredicateExecutor { 42 | 43 | ... 44 | } 45 | ``` 46 | `AccountSupportRepository`는 [step-15: Querydsl를 이용해서 Repository 확장하기(1)](https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-15.md) 에서 추가한 코드이고 `QuerydslPredicateExecutor` 코드만 추가하면 완료 됩니다. 47 | 48 | ## QuerydslPredicateExecutor 사용하기 49 | 50 | ![](../images/AccountRepository2.png) 51 | 52 | `AccountRepository`는 `QuerydslPredicateExecutor`를 구현하고 있음으로 별다른 코드 없이 우리는 `AccountRepository`를 통해서 `QuerydslPredicateExecutor`의 메서드를 사용할 수 있습니다. 이것은 우리가 `AccountRepository`가 아무 메서드가 없어도 `JpaRepository`에 있는 findById, findAll 같은 메서드를 사용할 수 있는 이유와 동일합니다. 53 | 54 | ### Test Code 55 | 56 | ```java 57 | @DataJpaTest 58 | @RunWith(SpringRunner.class) 59 | public class AccountRepositoryTest { 60 | 61 | @Autowired 62 | private AccountRepository accountRepository; 63 | 64 | private final QAccount qAccount = QAccount.account; 65 | 66 | @Test 67 | public void predicate_test_001() { 68 | //given 69 | final Predicate predicate = qAccount.email.eq(Email.of("test001@test.com")); 70 | 71 | //when 72 | final boolean exists = accountRepository.exists(predicate); 73 | 74 | //then 75 | assertThat(exists).isTrue(); 76 | } 77 | 78 | @Test 79 | public void predicate_test_002() { 80 | //given 81 | final Predicate predicate = qAccount.firstName.eq("test"); 82 | 83 | //when 84 | final boolean exists = accountRepository.exists(predicate); 85 | 86 | //then 87 | assertThat(exists).isFalse(); 88 | } 89 | 90 | @Test 91 | public void predicate_test_003() { 92 | //given 93 | final Predicate predicate = qAccount.email.value.like("test%"); 94 | 95 | //when 96 | final long count = accountRepository.count(predicate); 97 | 98 | //then 99 | assertThat(count).isGreaterThan(1); 100 | } 101 | 102 | } 103 | ``` 104 | Predicate 객체 생성을 통해서 쿼리메서드 코드 추가 없이 다양한 쿼리들을 쉽게 만들 수 있습니다. 105 | 106 | ![](../images/test-result.png) 107 | 108 | `predicate_test_001` 테스트 코드에 대한 실제 쿼리 내용입니다. 해당 쿼리가 어떻게 출력되는지 직접 눈으로 확인하는 습관을 갖는 것이 좋습니다. 109 | 110 | ## 결론 111 | 조회용 쿼리를 만드는 것은 실제 현업에서 많은 업무 비중을 차지하다 보니 작업자들은 다양한 조회 전용 구현체들을 우후죽순처럼 만들게 됩니다. 이렇게 되면 중복 코드가 많이 발생하게 되고, 구현체가 늘어나는 만큼 의존 관계도 자연스레 높아지게 됩니다. 112 | 113 | 이러한 문제를 위와 같이 객체지향 관점으로 풀어내어 `AccountRepository` 인터페이스를 통해서 DAO가 제공되고 세부 구현들을 숨길 수 있게 설계하는 것이 바람직하다고 생각합니다. 114 | 115 | **단순하게 JPA를 잘 활용하는 것보다 위에서 언급한 내용을 조금 더 깊게 생각해보는 것이 이 포스팅에 취지입니다.** 116 | 117 | -------------------------------------------------------------------------------- /images/AccountRepository.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/AccountRepository.png -------------------------------------------------------------------------------- /images/AccountRepository2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/AccountRepository2.png -------------------------------------------------------------------------------- /images/data-jpa-paging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/data-jpa-paging.png -------------------------------------------------------------------------------- /images/intellij-properties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/intellij-properties.png -------------------------------------------------------------------------------- /images/jpa-class-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/jpa-class-diagram.png -------------------------------------------------------------------------------- /images/junit5-display-name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/junit5-display-name.png -------------------------------------------------------------------------------- /images/postman-page-request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/postman-page-request.png -------------------------------------------------------------------------------- /images/querydsl-path.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/querydsl-path.png -------------------------------------------------------------------------------- /images/search-paging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/search-paging.png -------------------------------------------------------------------------------- /images/spring-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/spring-profile.png -------------------------------------------------------------------------------- /images/swagger-paging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/swagger-paging.png -------------------------------------------------------------------------------- /images/test-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheese10yun/spring-jpa-best-practices/a38b10014bec5ba1ef56e8bea062da7ec57db9c6/images/test-result.png -------------------------------------------------------------------------------- /lombok.config: -------------------------------------------------------------------------------- 1 | lombok.addLombokGeneratedAnnotation = true -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Maven2 Start Up Batch script 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # M2_HOME - location of maven2's installed home dir 31 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 32 | # e.g. to debug Maven itself, use 33 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 34 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 35 | # ---------------------------------------------------------------------------- 36 | 37 | if [ -z "$MAVEN_SKIP_RC" ] ; then 38 | 39 | if [ -f /etc/mavenrc ] ; then 40 | . /etc/mavenrc 41 | fi 42 | 43 | if [ -f "$HOME/.mavenrc" ] ; then 44 | . "$HOME/.mavenrc" 45 | fi 46 | 47 | fi 48 | 49 | # OS specific support. $var _must_ be set to either true or false. 50 | cygwin=false; 51 | darwin=false; 52 | mingw=false 53 | case "`uname`" in 54 | CYGWIN*) cygwin=true ;; 55 | MINGW*) mingw=true;; 56 | Darwin*) darwin=true 57 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 58 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 59 | if [ -z "$JAVA_HOME" ]; then 60 | if [ -x "/usr/libexec/java_home" ]; then 61 | export JAVA_HOME="`/usr/libexec/java_home`" 62 | else 63 | export JAVA_HOME="/Library/Java/Home" 64 | fi 65 | fi 66 | ;; 67 | esac 68 | 69 | if [ -z "$JAVA_HOME" ] ; then 70 | if [ -r /etc/gentoo-release ] ; then 71 | JAVA_HOME=`java-config --jre-home` 72 | fi 73 | fi 74 | 75 | if [ -z "$M2_HOME" ] ; then 76 | ## resolve links - $0 may be a link to maven's home 77 | PRG="$0" 78 | 79 | # need this for relative symlinks 80 | while [ -h "$PRG" ] ; do 81 | ls=`ls -ld "$PRG"` 82 | link=`expr "$ls" : '.*-> \(.*\)$'` 83 | if expr "$link" : '/.*' > /dev/null; then 84 | PRG="$link" 85 | else 86 | PRG="`dirname "$PRG"`/$link" 87 | fi 88 | done 89 | 90 | saveddir=`pwd` 91 | 92 | M2_HOME=`dirname "$PRG"`/.. 93 | 94 | # make it fully qualified 95 | M2_HOME=`cd "$M2_HOME" && pwd` 96 | 97 | cd "$saveddir" 98 | # echo Using m2 at $M2_HOME 99 | fi 100 | 101 | # For Cygwin, ensure paths are in UNIX format before anything is touched 102 | if $cygwin ; then 103 | [ -n "$M2_HOME" ] && 104 | M2_HOME=`cygpath --unix "$M2_HOME"` 105 | [ -n "$JAVA_HOME" ] && 106 | JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 107 | [ -n "$CLASSPATH" ] && 108 | CLASSPATH=`cygpath --path --unix "$CLASSPATH"` 109 | fi 110 | 111 | # For Migwn, ensure paths are in UNIX format before anything is touched 112 | if $mingw ; then 113 | [ -n "$M2_HOME" ] && 114 | M2_HOME="`(cd "$M2_HOME"; pwd)`" 115 | [ -n "$JAVA_HOME" ] && 116 | JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" 117 | # TODO classpath? 118 | fi 119 | 120 | if [ -z "$JAVA_HOME" ]; then 121 | javaExecutable="`which javac`" 122 | if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then 123 | # readlink(1) is not available as standard on Solaris 10. 124 | readLink=`which readlink` 125 | if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then 126 | if $darwin ; then 127 | javaHome="`dirname \"$javaExecutable\"`" 128 | javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" 129 | else 130 | javaExecutable="`readlink -f \"$javaExecutable\"`" 131 | fi 132 | javaHome="`dirname \"$javaExecutable\"`" 133 | javaHome=`expr "$javaHome" : '\(.*\)/bin'` 134 | JAVA_HOME="$javaHome" 135 | export JAVA_HOME 136 | fi 137 | fi 138 | fi 139 | 140 | if [ -z "$JAVACMD" ] ; then 141 | if [ -n "$JAVA_HOME" ] ; then 142 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 143 | # IBM's JDK on AIX uses strange locations for the executables 144 | JAVACMD="$JAVA_HOME/jre/sh/java" 145 | else 146 | JAVACMD="$JAVA_HOME/bin/java" 147 | fi 148 | else 149 | JAVACMD="`which java`" 150 | fi 151 | fi 152 | 153 | if [ ! -x "$JAVACMD" ] ; then 154 | echo "Error: JAVA_HOME is not defined correctly." >&2 155 | echo " We cannot execute $JAVACMD" >&2 156 | exit 1 157 | fi 158 | 159 | if [ -z "$JAVA_HOME" ] ; then 160 | echo "Warning: JAVA_HOME environment variable is not set." 161 | fi 162 | 163 | CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher 164 | 165 | # traverses directory structure from process work directory to filesystem root 166 | # first directory with .mvn subdirectory is considered project base directory 167 | find_maven_basedir() { 168 | 169 | if [ -z "$1" ] 170 | then 171 | echo "Path not specified to find_maven_basedir" 172 | return 1 173 | fi 174 | 175 | basedir="$1" 176 | wdir="$1" 177 | while [ "$wdir" != '/' ] ; do 178 | if [ -d "$wdir"/.mvn ] ; then 179 | basedir=$wdir 180 | break 181 | fi 182 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 183 | if [ -d "${wdir}" ]; then 184 | wdir=`cd "$wdir/.."; pwd` 185 | fi 186 | # end of workaround 187 | done 188 | echo "${basedir}" 189 | } 190 | 191 | # concatenates all lines of a file 192 | concat_lines() { 193 | if [ -f "$1" ]; then 194 | echo "$(tr -s '\n' ' ' < "$1")" 195 | fi 196 | } 197 | 198 | BASE_DIR=`find_maven_basedir "$(pwd)"` 199 | if [ -z "$BASE_DIR" ]; then 200 | exit 1; 201 | fi 202 | 203 | export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} 204 | echo $MAVEN_PROJECTBASEDIR 205 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 206 | 207 | # For Cygwin, switch paths to Windows format before running java 208 | if $cygwin; then 209 | [ -n "$M2_HOME" ] && 210 | M2_HOME=`cygpath --path --windows "$M2_HOME"` 211 | [ -n "$JAVA_HOME" ] && 212 | JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` 213 | [ -n "$CLASSPATH" ] && 214 | CLASSPATH=`cygpath --path --windows "$CLASSPATH"` 215 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 216 | MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` 217 | fi 218 | 219 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 220 | 221 | exec "$JAVACMD" \ 222 | $MAVEN_OPTS \ 223 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 224 | "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 225 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 226 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM http://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Maven2 Start Up Batch script 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' 39 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 40 | 41 | @REM set %HOME% to equivalent of $HOME 42 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 43 | 44 | @REM Execute a user defined script before this one 45 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 46 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 47 | if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" 48 | if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" 49 | :skipRcPre 50 | 51 | @setlocal 52 | 53 | set ERROR_CODE=0 54 | 55 | @REM To isolate internal variables from possible post scripts, we use another setlocal 56 | @setlocal 57 | 58 | @REM ==== START VALIDATION ==== 59 | if not "%JAVA_HOME%" == "" goto OkJHome 60 | 61 | echo. 62 | echo Error: JAVA_HOME not found in your environment. >&2 63 | echo Please set the JAVA_HOME variable in your environment to match the >&2 64 | echo location of your Java installation. >&2 65 | echo. 66 | goto error 67 | 68 | :OkJHome 69 | if exist "%JAVA_HOME%\bin\java.exe" goto init 70 | 71 | echo. 72 | echo Error: JAVA_HOME is set to an invalid directory. >&2 73 | echo JAVA_HOME = "%JAVA_HOME%" >&2 74 | echo Please set the JAVA_HOME variable in your environment to match the >&2 75 | echo location of your Java installation. >&2 76 | echo. 77 | goto error 78 | 79 | @REM ==== END VALIDATION ==== 80 | 81 | :init 82 | 83 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 84 | @REM Fallback to current working directory if not found. 85 | 86 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 87 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 88 | 89 | set EXEC_DIR=%CD% 90 | set WDIR=%EXEC_DIR% 91 | :findBaseDir 92 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 93 | cd .. 94 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 95 | set WDIR=%CD% 96 | goto findBaseDir 97 | 98 | :baseDirFound 99 | set MAVEN_PROJECTBASEDIR=%WDIR% 100 | cd "%EXEC_DIR%" 101 | goto endDetectBaseDir 102 | 103 | :baseDirNotFound 104 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 105 | cd "%EXEC_DIR%" 106 | 107 | :endDetectBaseDir 108 | 109 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 110 | 111 | @setlocal EnableExtensions EnableDelayedExpansion 112 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 113 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 114 | 115 | :endReadAdditionalConfig 116 | 117 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 118 | 119 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 120 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 121 | 122 | %MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 123 | if ERRORLEVEL 1 goto error 124 | goto end 125 | 126 | :error 127 | set ERROR_CODE=1 128 | 129 | :end 130 | @endlocal & set ERROR_CODE=%ERROR_CODE% 131 | 132 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost 133 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 134 | if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" 135 | if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" 136 | :skipRcPost 137 | 138 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 139 | if "%MAVEN_BATCH_PAUSE%" == "on" pause 140 | 141 | if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% 142 | 143 | exit /B %ERROR_CODE% 144 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.cheese 7 | spring-jpa-best-practices 8 | 0.0.1-SNAPSHOT 9 | jar 10 | 11 | spring-jpa-best-practices 12 | Spring JPA best practices 13 | 14 | 15 | 16 | org.springframework.boot 17 | spring-boot-starter-parent 18 | 2.1.1.RELEASE 19 | 20 | 21 | 22 | 23 | UTF-8 24 | UTF-8 25 | 1.8 26 | 27 | 28 | 29 | 30 | org.springframework.boot 31 | spring-boot-starter-data-jpa 32 | 33 | 34 | 35 | org.springframework.boot 36 | spring-boot-starter-jdbc 37 | 38 | 39 | 40 | org.springframework.boot 41 | spring-boot-starter-web 42 | 43 | 44 | 45 | org.springframework.boot 46 | spring-boot-starter-security 47 | 48 | 49 | 50 | com.h2database 51 | h2 52 | runtime 53 | 54 | 55 | 56 | org.springframework.boot 57 | spring-boot-devtools 58 | runtime 59 | 60 | 61 | 62 | org.projectlombok 63 | lombok 64 | true 65 | 66 | 67 | 68 | org.springframework.boot 69 | spring-boot-starter-test 70 | test 71 | 72 | 73 | junit 74 | junit 75 | 76 | 77 | 78 | 79 | 80 | org.junit.jupiter 81 | junit-jupiter-api 82 | 5.3.2 83 | test 84 | 85 | 86 | 87 | org.mockito 88 | mockito-core 89 | test 90 | 91 | 92 | 93 | org.mockito 94 | mockito-junit-jupiter 95 | test 96 | 97 | 98 | 99 | org.junit.platform 100 | junit-platform-runner 101 | 1.2.0 102 | test 103 | 104 | 105 | 106 | org.junit.vintage 107 | junit-vintage-engine 108 | 5.2.0 109 | test 110 | 111 | 112 | 113 | com.querydsl 114 | querydsl-apt 115 | 116 | 117 | 118 | com.querydsl 119 | querydsl-jpa 120 | 121 | 122 | 123 | io.springfox 124 | springfox-swagger2 125 | 2.7.0 126 | 127 | 128 | 129 | io.springfox 130 | springfox-swagger-ui 131 | 2.7.0 132 | 133 | 134 | 135 | com.fasterxml.jackson.datatype 136 | jackson-datatype-jsr310 137 | 138 | 139 | 140 | org.springframework.boot 141 | spring-boot-configuration-processor 142 | true 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | org.springframework.boot 152 | spring-boot-maven-plugin 153 | 154 | 155 | 156 | org.eluder.coveralls 157 | coveralls-maven-plugin 158 | 4.3.0 159 | 160 | oTJ7CTNqWrWtEy83kkjqtvA4gkhDXCngW 161 | 162 | 163 | 164 | 165 | org.jacoco 166 | jacoco-maven-plugin 167 | 0.8.3 168 | 169 | 170 | prepare-agent 171 | 172 | prepare-agent 173 | 174 | 175 | 176 | 177 | 178 | 179 | **/Q*.class 180 | 181 | 182 | 183 | 184 | 185 | 186 | com.mysema.maven 187 | apt-maven-plugin 188 | 1.1.3 189 | 190 | 191 | 192 | process 193 | 194 | 195 | target/generated-sources/java 196 | com.querydsl.apt.jpa.JPAAnnotationProcessor 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | -------------------------------------------------------------------------------- /rest/account/account_create.json: -------------------------------------------------------------------------------- 1 | { 2 | "content": [ 3 | { 4 | "email": { 5 | "value": "test001@test.com" 6 | }, 7 | "password": { 8 | "value": "$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6", 9 | "expirationDate": "+20120-01-20T00:00:00", 10 | "failedCount": 0, 11 | "ttl": 1209604, 12 | "expiration": false 13 | }, 14 | "fistName": "first", 15 | "lastName": "last", 16 | "address": { 17 | "address1": "address1", 18 | "address2": "address2", 19 | "zip": "002" 20 | } 21 | } 22 | ... 23 | ], 24 | "pageable": { 25 | "sort": { 26 | "sorted": false, 27 | "unsorted": true, 28 | "empty": true 29 | }, 30 | "offset": 0, 31 | "pageSize": 20, 32 | "pageNumber": 0, 33 | "paged": true, 34 | "unpaged": false 35 | }, 36 | "last": true, 37 | "totalPages": 1, 38 | "totalElements": 13, 39 | "size": 20, 40 | "number": 0, 41 | "sort": { 42 | "sorted": false, 43 | "unsorted": true, 44 | "empty": true 45 | }, 46 | "numberOfElements": 13, 47 | "first": true, 48 | "empty": false 49 | } -------------------------------------------------------------------------------- /rest/account/account_update.json: -------------------------------------------------------------------------------- 1 | { 2 | "address1": "2", 3 | "address2": "2", 4 | "zip": "string" 5 | } -------------------------------------------------------------------------------- /rest/account/accounts.http: -------------------------------------------------------------------------------- 1 | #Account Request 2 | 3 | ## Account 조회 4 | GET http://localhost:8080/accounts/1 5 | Content-Type: application/json 6 | ### 7 | 8 | ## Account 생성 9 | POST http://localhost:8080/accounts 10 | Content-Type: application/json 11 | 12 | < ./account_create.json 13 | ### 14 | 15 | ## Account 수정 16 | PUT http://localhost:8080/accounts/1 17 | Content-Type: application/json 18 | 19 | < ./account_update.json 20 | ### -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/api/AccountController.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.api; 2 | 3 | import com.cheese.springjpa.Account.application.AccountService; 4 | import com.cheese.springjpa.Account.dao.AccountRepository; 5 | import com.cheese.springjpa.Account.dao.AccountSearchService; 6 | import com.cheese.springjpa.Account.dto.AccountDto; 7 | import com.cheese.springjpa.Account.dto.AccountDto.Res; 8 | import com.cheese.springjpa.Account.dto.AccountSearchType; 9 | import com.cheese.springjpa.common.model.PageRequest; 10 | import javax.validation.Valid; 11 | import lombok.RequiredArgsConstructor; 12 | import org.springframework.data.domain.Page; 13 | import org.springframework.http.HttpStatus; 14 | import org.springframework.web.bind.annotation.GetMapping; 15 | import org.springframework.web.bind.annotation.PathVariable; 16 | import org.springframework.web.bind.annotation.PostMapping; 17 | import org.springframework.web.bind.annotation.PutMapping; 18 | import org.springframework.web.bind.annotation.RequestBody; 19 | import org.springframework.web.bind.annotation.RequestMapping; 20 | import org.springframework.web.bind.annotation.RequestParam; 21 | import org.springframework.web.bind.annotation.ResponseStatus; 22 | import org.springframework.web.bind.annotation.RestController; 23 | 24 | 25 | @RestController 26 | @RequestMapping("accounts") 27 | @RequiredArgsConstructor 28 | public class AccountController { 29 | 30 | private final AccountService accountService; 31 | private final AccountSearchService accountSearchService; 32 | private final AccountRepository accountRepository; 33 | 34 | // private final AccountRepository accountRepository; 35 | 36 | 37 | @PostMapping 38 | @ResponseStatus(value = HttpStatus.CREATED) 39 | public Res signUp(@RequestBody @Valid final AccountDto.SignUpReq dto) { 40 | return new AccountDto.Res(accountService.create(dto)); 41 | } 42 | 43 | @GetMapping 44 | public Page getAccounts( 45 | @RequestParam(name = "type") final AccountSearchType type, 46 | @RequestParam(name = "value", required = false) final String value, 47 | final PageRequest pageRequest 48 | ) { 49 | return accountSearchService.search(type, value, pageRequest.of()).map(AccountDto.Res::new); 50 | } 51 | 52 | // step-12 컨트롤러 코드 53 | // @GetMapping 54 | // public Page getAccounts(final PageRequest pageable) { 55 | // return accountService.findAll(pageable.of()).map(AccountDto.Res::new); 56 | // } 57 | 58 | // 기본 Pageable을 사용한 코드 59 | // @GetMapping 60 | // public Page getAccounts(final Pageable pageable) { 61 | // return accountService.findAll(pageable).map(AccountDto.Res::new); 62 | // } 63 | 64 | @GetMapping(value = "/{id}") 65 | @ResponseStatus(value = HttpStatus.OK) 66 | public AccountDto.Res getUser(@PathVariable final long id) { 67 | return new AccountDto.Res(accountService.findById(id)); 68 | } 69 | 70 | 71 | // @RequestMapping(method = RequestMethod.GET) 72 | // @ResponseStatus(value = HttpStatus.OK) 73 | // public AccountDto.Res getUserByEmail(@Valid Email email) { 74 | // return new AccountDto.Res(accountService.findByEmail(email)); 75 | // } 76 | 77 | @PutMapping(value = "/{id}") 78 | @ResponseStatus(value = HttpStatus.OK) 79 | public AccountDto.Res updateMyAccount(@PathVariable final long id, @RequestBody final AccountDto.MyAccountReq dto) { 80 | return new AccountDto.Res(accountService.updateMyAccount(id, dto)); 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/application/AccountService.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.application; 2 | 3 | import com.cheese.springjpa.Account.dao.AccountRepository; 4 | import com.cheese.springjpa.Account.domain.Account; 5 | import com.cheese.springjpa.Account.domain.Email; 6 | import com.cheese.springjpa.Account.dto.AccountDto; 7 | import com.cheese.springjpa.Account.exception.AccountNotFoundException; 8 | import com.cheese.springjpa.Account.exception.EmailDuplicationException; 9 | import java.util.Optional; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.stereotype.Service; 12 | import org.springframework.transaction.annotation.Transactional; 13 | 14 | @Service 15 | @Transactional 16 | @RequiredArgsConstructor 17 | public class AccountService { 18 | 19 | private final AccountRepository accountRepository; 20 | 21 | 22 | @Transactional(readOnly = true) 23 | public Account findById(long id) { 24 | final Optional account = accountRepository.findById(id); 25 | account.orElseThrow(() -> new AccountNotFoundException(id)); 26 | return account.get(); 27 | } 28 | 29 | @Transactional(readOnly = true) 30 | public Account findByEmail(final Email email) { 31 | final Account account = accountRepository.findByEmail(email); 32 | if (account == null) throw new AccountNotFoundException(email); 33 | return account; 34 | } 35 | 36 | // @Transactional(readOnly = true) 37 | // public Page findAll(Pageable pageable) { 38 | // return accountRepository.findAll(pageable); 39 | // } 40 | 41 | @Transactional(readOnly = true) 42 | public boolean isExistedEmail(Email email) { 43 | return accountRepository.findByEmail(email) != null; 44 | } 45 | 46 | public Account updateMyAccount(long id, AccountDto.MyAccountReq dto) { 47 | final Account account = findById(id); 48 | account.updateMyAccount(dto); 49 | return account; 50 | } 51 | 52 | public Account create(AccountDto.SignUpReq dto) { 53 | if (isExistedEmail(dto.getEmail())) 54 | throw new EmailDuplicationException(dto.getEmail()); 55 | return accountRepository.save(dto.toEntity()); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/dao/AccountFindService.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.dao; 2 | 3 | import com.cheese.springjpa.Account.domain.Account; 4 | import com.cheese.springjpa.Account.domain.Email; 5 | import com.cheese.springjpa.Account.exception.AccountNotFoundException; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.stereotype.Service; 8 | import org.springframework.transaction.annotation.Transactional; 9 | 10 | @Service 11 | @Transactional 12 | @RequiredArgsConstructor 13 | public class AccountFindService { 14 | 15 | private final AccountRepository accountRepository; 16 | 17 | @Transactional(readOnly = true) 18 | public Account findById(long id) { 19 | return accountRepository.findById(id).orElseThrow(() -> new AccountNotFoundException(id)); 20 | } 21 | 22 | @Transactional(readOnly = true) 23 | public Account findByEmail(final Email email) { 24 | final Account account = accountRepository.findByEmail(email); 25 | if (account == null) throw new AccountNotFoundException(email); 26 | return account; 27 | } 28 | 29 | @Transactional(readOnly = true) 30 | public boolean isExistedEmail(Email email) { 31 | return accountRepository.existsByEmail(email); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/dao/AccountRepository.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.dao; 2 | 3 | import com.cheese.springjpa.Account.domain.Account; 4 | import com.cheese.springjpa.Account.domain.Email; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.data.querydsl.QuerydslPredicateExecutor; 7 | 8 | public interface AccountRepository extends JpaRepository, AccountSupportRepository, 9 | QuerydslPredicateExecutor { 10 | 11 | Account findByEmail(Email email); 12 | 13 | boolean existsByEmail(Email email); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/dao/AccountSearchService.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.dao; 2 | 3 | 4 | import com.cheese.springjpa.Account.domain.Account; 5 | import com.cheese.springjpa.Account.domain.QAccount; 6 | import com.cheese.springjpa.Account.dto.AccountSearchType; 7 | import com.querydsl.jpa.JPQLQuery; 8 | import java.util.List; 9 | import org.springframework.data.domain.Page; 10 | import org.springframework.data.domain.PageImpl; 11 | import org.springframework.data.domain.Pageable; 12 | import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport; 13 | import org.springframework.stereotype.Service; 14 | import org.springframework.transaction.annotation.Transactional; 15 | 16 | 17 | @Service 18 | @Transactional(readOnly = true) 19 | public class AccountSearchService extends QuerydslRepositorySupport { 20 | 21 | public AccountSearchService() { 22 | super(Account.class); 23 | } 24 | 25 | public Page search(final AccountSearchType type, final String value, final Pageable pageable) { 26 | final QAccount account = QAccount.account; 27 | final JPQLQuery query; 28 | 29 | switch (type) { 30 | case EMAIL: 31 | query = from(account) 32 | .where(account.email.value.likeIgnoreCase(value + "%")); 33 | break; 34 | case NAME: 35 | query = from(account) 36 | .where(account.firstName.likeIgnoreCase(value + "%") 37 | .or(account.lastName.likeIgnoreCase(value + "%"))); 38 | break; 39 | case ALL: 40 | query = from(account).fetchAll(); 41 | break; 42 | default: 43 | throw new IllegalArgumentException(); 44 | } 45 | final List accounts = getQuerydsl().applyPagination(pageable, query).fetch(); 46 | return new PageImpl<>(accounts, pageable, query.fetchCount()); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/dao/AccountSupportRepository.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.dao; 2 | 3 | import com.cheese.springjpa.Account.domain.Account; 4 | import java.util.List; 5 | 6 | public interface AccountSupportRepository { 7 | 8 | List findRecentlyRegistered(int limit); 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/dao/AccountSupportRepositoryImpl.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.dao; 2 | 3 | 4 | 5 | import com.cheese.springjpa.Account.domain.Account; 6 | import com.cheese.springjpa.Account.domain.QAccount; 7 | import java.util.List; 8 | import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport; 9 | import org.springframework.transaction.annotation.Transactional; 10 | 11 | @Transactional(readOnly = true) 12 | public class AccountSupportRepositoryImpl extends QuerydslRepositorySupport implements 13 | AccountSupportRepository { 14 | 15 | public AccountSupportRepositoryImpl() { 16 | super(Account.class); 17 | } 18 | 19 | @Override 20 | public List findRecentlyRegistered(int limit) { 21 | final QAccount account = QAccount.account; 22 | return from(account) 23 | .limit(limit) 24 | .orderBy(account.createdAt.desc()) 25 | .fetch(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/domain/Account.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.domain; 2 | 3 | import com.cheese.springjpa.Account.dto.AccountDto.MyAccountReq; 4 | import java.time.LocalDateTime; 5 | import javax.persistence.Column; 6 | import javax.persistence.Embedded; 7 | import javax.persistence.Entity; 8 | import javax.persistence.GeneratedValue; 9 | import javax.persistence.Id; 10 | import javax.persistence.Table; 11 | import lombok.AccessLevel; 12 | import lombok.Builder; 13 | import lombok.Getter; 14 | import lombok.NoArgsConstructor; 15 | import org.springframework.data.annotation.CreatedDate; 16 | import org.springframework.data.annotation.LastModifiedDate; 17 | 18 | @Entity 19 | @Table(name = "account") 20 | @Getter 21 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 22 | public class Account { 23 | 24 | @Id 25 | @GeneratedValue 26 | private long id; 27 | 28 | @Embedded 29 | private Email email; 30 | 31 | @Column(name = "first_name", nullable = false) 32 | private String firstName; 33 | 34 | @Column(name = "last_name", nullable = false) 35 | private String lastName; 36 | 37 | @Embedded 38 | private Password password; 39 | 40 | @Embedded 41 | private Address address; 42 | 43 | @CreatedDate 44 | @Column(name = "created_at", updatable = false) 45 | private LocalDateTime createdAt; 46 | 47 | @LastModifiedDate 48 | @Column(name = "update_at", nullable = false, updatable = false) 49 | private LocalDateTime updatedAt; 50 | 51 | @Builder 52 | public Account(Email email, String firstName, String lastName, Password password, Address address) { 53 | this.email = email; 54 | this.firstName = firstName; 55 | this.lastName = lastName; 56 | this.password = password; 57 | this.address = address; 58 | } 59 | 60 | 61 | public void updateMyAccount(MyAccountReq dto) { 62 | this.address = dto.getAddress(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/domain/Address.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.domain; 2 | 3 | import javax.persistence.Column; 4 | import javax.persistence.Embeddable; 5 | import javax.validation.constraints.NotEmpty; 6 | import lombok.AccessLevel; 7 | import lombok.Builder; 8 | import lombok.Getter; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Embeddable 12 | @Getter 13 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 14 | public class Address { 15 | 16 | @NotEmpty 17 | @Column(name = "address1", nullable = false) 18 | private String address1; 19 | 20 | @NotEmpty 21 | @Column(name = "address2", nullable = false) 22 | private String address2; 23 | 24 | @NotEmpty 25 | @Column(name = "zip", nullable = false) 26 | private String zip; 27 | 28 | @Builder 29 | public Address(String address1, String address2, String zip) { 30 | this.address1 = address1; 31 | this.address2 = address2; 32 | this.zip = zip; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/domain/Email.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.domain; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import javax.persistence.Column; 5 | import javax.persistence.Embeddable; 6 | import lombok.AccessLevel; 7 | import lombok.Builder; 8 | import lombok.Getter; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Embeddable 12 | @Getter 13 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 14 | @JsonIgnoreProperties({"host", "id"}) 15 | public class Email { 16 | 17 | @javax.validation.constraints.Email 18 | @Column(name = "email", nullable = false, unique = true) 19 | private String value; 20 | 21 | @Builder 22 | public Email(String value) { 23 | this.value = value; 24 | } 25 | 26 | public static Email of(String email) { 27 | return new Email(email); 28 | } 29 | 30 | public String getHost() { 31 | int index = value.indexOf("@"); 32 | return value.substring(index); 33 | } 34 | 35 | public String getId() { 36 | int index = value.indexOf("@"); 37 | return value.substring(0, index); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/domain/Password.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.domain; 2 | 3 | import com.cheese.springjpa.Account.exception.PasswordFailedExceededException; 4 | import java.time.LocalDateTime; 5 | import javax.persistence.Column; 6 | import javax.persistence.Embeddable; 7 | import lombok.AccessLevel; 8 | import lombok.Builder; 9 | import lombok.Getter; 10 | import lombok.NoArgsConstructor; 11 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 12 | 13 | 14 | @Embeddable 15 | @Getter 16 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 17 | public class Password { 18 | 19 | @Column(name = "password", nullable = false) 20 | private String value; 21 | 22 | @Column(name = "password_expiration_date") 23 | private LocalDateTime expirationDate; 24 | 25 | @Column(name = "password_failed_count", nullable = false) 26 | private int failedCount; 27 | 28 | @Column(name = "password_ttl") 29 | private long ttl; 30 | 31 | @Builder 32 | public Password(final String value) { 33 | this.ttl = 1209_604; // 1209_604 is 14 days 34 | this.value = encodePassword(value); 35 | this.expirationDate = extendExpirationDate(); 36 | } 37 | 38 | public boolean isMatched(final String rawPassword) { 39 | if (failedCount >= 5) 40 | throw new PasswordFailedExceededException(); 41 | 42 | final boolean matches = isMatches(rawPassword); 43 | updateFailedCount(matches); 44 | return matches; 45 | } 46 | 47 | public void changePassword(final String newPassword, final String oldPassword) { 48 | if (isMatched(oldPassword)) { 49 | value = encodePassword(newPassword); 50 | extendExpirationDate(); 51 | } 52 | } 53 | 54 | 55 | public boolean isExpiration() { 56 | return LocalDateTime.now().isAfter(expirationDate); 57 | } 58 | 59 | private LocalDateTime extendExpirationDate() { 60 | return LocalDateTime.now().plusSeconds(ttl); 61 | } 62 | 63 | private String encodePassword(final String password) { 64 | return new BCryptPasswordEncoder().encode(password); 65 | } 66 | 67 | private void updateFailedCount(boolean matches) { 68 | if (matches) 69 | resetFailedCount(); 70 | else 71 | increaseFailCount(); 72 | } 73 | 74 | private void resetFailedCount() { 75 | this.failedCount = 0; 76 | } 77 | 78 | private void increaseFailCount() { 79 | this.failedCount++; 80 | } 81 | 82 | private boolean isMatches(String rawPassword) { 83 | return new BCryptPasswordEncoder().matches(rawPassword, this.value); 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/dto/AccountDto.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.dto; 2 | 3 | import com.cheese.springjpa.Account.domain.Account; 4 | import com.cheese.springjpa.Account.domain.Address; 5 | import com.cheese.springjpa.Account.domain.Email; 6 | import com.cheese.springjpa.Account.domain.Password; 7 | import javax.validation.Valid; 8 | import javax.validation.constraints.NotEmpty; 9 | import lombok.AccessLevel; 10 | import lombok.Builder; 11 | import lombok.Getter; 12 | import lombok.NoArgsConstructor; 13 | 14 | public class AccountDto { 15 | 16 | @Getter 17 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 18 | public static class SignUpReq { 19 | 20 | @Valid 21 | private Email email; 22 | @NotEmpty 23 | private String fistName; 24 | @NotEmpty 25 | private String lastName; 26 | 27 | private String password; 28 | 29 | @Valid 30 | private Address address; 31 | 32 | @Builder 33 | public SignUpReq(Email email, String fistName, String lastName, String password, Address address) { 34 | this.email = email; 35 | this.fistName = fistName; 36 | this.lastName = lastName; 37 | this.password = password; 38 | this.address = address; 39 | } 40 | 41 | public Account toEntity() { 42 | return Account.builder() 43 | .email(this.email) 44 | .firstName(this.fistName) 45 | .lastName(this.lastName) 46 | .password(Password.builder().value(this.password).build()) 47 | .address(this.address) 48 | .build(); 49 | } 50 | 51 | } 52 | 53 | @Getter 54 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 55 | public static class MyAccountReq { 56 | private Address address; 57 | 58 | @Builder 59 | public MyAccountReq(final Address address) { 60 | this.address = address; 61 | } 62 | 63 | } 64 | 65 | @Getter 66 | public static class Res { 67 | 68 | private Email email; 69 | private Password password; 70 | private String fistName; 71 | private String lastName; 72 | private Address address; 73 | 74 | public Res(Account account) { 75 | this.email = account.getEmail(); 76 | this.fistName = account.getFirstName(); 77 | this.lastName = account.getLastName(); 78 | this.address = account.getAddress(); 79 | this.password = account.getPassword(); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/dto/AccountSearchType.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.dto; 2 | 3 | public enum AccountSearchType { 4 | EMAIL, 5 | NAME, 6 | ALL 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/exception/AccountNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.exception; 2 | 3 | import com.cheese.springjpa.Account.domain.Email; 4 | import lombok.Getter; 5 | 6 | @Getter 7 | public class AccountNotFoundException extends RuntimeException { 8 | 9 | private long id; 10 | private Email email; 11 | 12 | public AccountNotFoundException(long id) { 13 | this.id = id; 14 | } 15 | 16 | public AccountNotFoundException(Email email) { 17 | this.email = email; 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/exception/EmailDuplicationException.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.exception; 2 | 3 | import com.cheese.springjpa.Account.domain.Email; 4 | import lombok.Getter; 5 | 6 | @Getter 7 | public class EmailDuplicationException extends RuntimeException { 8 | 9 | private Email email; 10 | private String field; 11 | 12 | public EmailDuplicationException(Email email) { 13 | this.field = "email"; 14 | this.email = email; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/Account/exception/PasswordFailedExceededException.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.exception; 2 | 3 | import com.cheese.springjpa.error.ErrorCode; 4 | import lombok.Getter; 5 | 6 | @Getter 7 | public class PasswordFailedExceededException extends RuntimeException { 8 | 9 | private ErrorCode errorCode; 10 | 11 | public PasswordFailedExceededException() { 12 | this.errorCode = ErrorCode.PASSWORD_FAILED_EXCEEDED; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/SpringJpaApplication.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.autoconfigure.domain.EntityScan; 6 | import org.springframework.data.jpa.convert.threeten.Jsr310JpaConverters; 7 | 8 | @SpringBootApplication 9 | @EntityScan( 10 | basePackageClasses = {Jsr310JpaConverters.class}, // basePackageClasses에 지정 11 | basePackages = {"com"}) 12 | public class SpringJpaApplication { 13 | 14 | public static void main(String[] args) { 15 | SpringApplication.run(SpringJpaApplication.class, args); 16 | } 17 | 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/common/model/DateTime.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.common.model; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.Builder; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import org.springframework.data.annotation.CreatedDate; 8 | import org.springframework.data.annotation.LastModifiedDate; 9 | import org.springframework.data.jpa.domain.support.AuditingEntityListener; 10 | 11 | import javax.persistence.Column; 12 | import javax.persistence.Embeddable; 13 | import javax.persistence.EntityListeners; 14 | import java.time.LocalDateTime; 15 | 16 | @Embeddable 17 | @Getter 18 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 19 | public class DateTime { 20 | 21 | @CreatedDate 22 | @Column(name = "created_at", updatable = false) 23 | private LocalDateTime createdAt; 24 | 25 | @LastModifiedDate 26 | @Column(name = "update_at") 27 | private LocalDateTime updatedAt; 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/common/model/PageRequest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.common.model; 2 | 3 | import org.springframework.data.domain.Sort; 4 | 5 | public final class PageRequest { 6 | 7 | private int page; 8 | private int size; 9 | private Sort.Direction direction; 10 | 11 | public void setPage(int page) { 12 | this.page = page <= 0 ? 1 : page; 13 | } 14 | 15 | public void setSize(int size) { 16 | int DEFAULT_SIZE = 10; 17 | int MAX_SIZE = 50; 18 | this.size = size > MAX_SIZE ? DEFAULT_SIZE : size; 19 | } 20 | 21 | public void setDirection(Sort.Direction direction) { 22 | this.direction = direction; 23 | } 24 | 25 | public int getPage() { 26 | return page; 27 | } 28 | 29 | public int getSize() { 30 | return size; 31 | } 32 | 33 | public Sort.Direction getDirection() { 34 | return direction; 35 | } 36 | 37 | public org.springframework.data.domain.PageRequest of() { 38 | return org.springframework.data.domain.PageRequest.of(page - 1, size, direction, "createdAt"); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/config/SwaggerConfig.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import springfox.documentation.builders.PathSelectors; 6 | import springfox.documentation.builders.RequestHandlerSelectors; 7 | import springfox.documentation.spi.DocumentationType; 8 | import springfox.documentation.spring.web.plugins.Docket; 9 | import springfox.documentation.swagger2.annotations.EnableSwagger2; 10 | 11 | import java.sql.Timestamp; 12 | 13 | @EnableSwagger2 14 | @Configuration 15 | public class SwaggerConfig { 16 | 17 | @Bean 18 | public Docket api() { 19 | return new Docket(DocumentationType.SWAGGER_2) 20 | .select() 21 | .apis(RequestHandlerSelectors.any()) 22 | .paths(PathSelectors.any()) 23 | .build() 24 | .directModelSubstitute(Timestamp.class, Long.class); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/config/WebSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 5 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 6 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 7 | 8 | @Configuration 9 | @EnableWebSecurity 10 | public class WebSecurityConfig extends WebSecurityConfigurerAdapter { 11 | 12 | private final String[] AUTH_WHITELIST = { 13 | // -- swagger ui 14 | "/swagger-resources/**", 15 | "/swagger-ui.html", 16 | "/v2/api-docs", 17 | "/webjars/**" 18 | }; 19 | 20 | @Override 21 | protected void configure(HttpSecurity httpSecurity) throws Exception { 22 | httpSecurity 23 | // we don't need CSRF because our token is invulnerable 24 | .csrf().disable() 25 | .authorizeRequests() 26 | // browser 27 | .antMatchers("/browser/**").permitAll() 28 | .antMatchers(AUTH_WHITELIST).permitAll() 29 | .anyRequest().permitAll(); 30 | 31 | 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/coupon/Coupon.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.coupon; 2 | 3 | 4 | import com.cheese.springjpa.order.Order; 5 | import com.fasterxml.jackson.annotation.JsonIgnore; 6 | import lombok.AccessLevel; 7 | import lombok.Builder; 8 | import lombok.Getter; 9 | import lombok.NoArgsConstructor; 10 | 11 | import javax.persistence.*; 12 | 13 | @Entity 14 | @Table(name = "coupon") 15 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 16 | @Getter 17 | public class Coupon { 18 | 19 | @Id 20 | @GeneratedValue 21 | private long id; 22 | 23 | @Column(name = "discount_amount") 24 | private double discountAmount; 25 | 26 | @Column(name = "use") 27 | private boolean use; 28 | 29 | @JsonIgnore 30 | @OneToOne(mappedBy = "coupon") 31 | private Order order; 32 | 33 | @Builder 34 | public Coupon(double discountAmount) { 35 | this.discountAmount = discountAmount; 36 | this.use = false; 37 | } 38 | 39 | public void use(final Order order) { 40 | this.order = order; 41 | this.use = true; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/coupon/CouponRepository.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.coupon; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | public interface CouponRepository extends JpaRepository { 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/coupon/CouponService.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.coupon; 2 | 3 | import lombok.AllArgsConstructor; 4 | import org.springframework.stereotype.Service; 5 | import org.springframework.transaction.annotation.Transactional; 6 | 7 | @Service 8 | @Transactional 9 | @AllArgsConstructor 10 | public class CouponService { 11 | 12 | private final CouponRepository couponRepository; 13 | 14 | public Coupon findById(long id) { 15 | return couponRepository.findById(id).get(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/delivery/Delivery.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery; 2 | 3 | import com.cheese.springjpa.Account.domain.Address; 4 | import com.cheese.springjpa.common.model.DateTime; 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | import javax.persistence.CascadeType; 8 | import javax.persistence.Embedded; 9 | import javax.persistence.Entity; 10 | import javax.persistence.FetchType; 11 | import javax.persistence.GeneratedValue; 12 | import javax.persistence.GenerationType; 13 | import javax.persistence.Id; 14 | import javax.persistence.OneToMany; 15 | import javax.persistence.Table; 16 | import lombok.AccessLevel; 17 | import lombok.Builder; 18 | import lombok.Getter; 19 | import lombok.NoArgsConstructor; 20 | import lombok.ToString; 21 | 22 | @Entity 23 | @Table(name = "delivery") 24 | @Getter 25 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 26 | @ToString 27 | public class Delivery { 28 | 29 | @Id 30 | @GeneratedValue(strategy = GenerationType.AUTO) 31 | private long id; 32 | 33 | @Embedded 34 | private Address address; 35 | 36 | @OneToMany(mappedBy = "delivery", cascade = CascadeType.PERSIST, orphanRemoval = true, fetch = FetchType.EAGER) 37 | private List logs = new ArrayList<>(); 38 | 39 | @Embedded 40 | private DateTime dateTime; 41 | 42 | 43 | @Builder 44 | public Delivery(Address address) { 45 | this.address = address; 46 | } 47 | 48 | 49 | public void addLog(DeliveryStatus status) { 50 | this.logs.add(buildLog(status)); 51 | } 52 | 53 | private DeliveryLog buildLog(DeliveryStatus status) { 54 | return DeliveryLog.builder() 55 | .status(status) 56 | .delivery(this) 57 | .build(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/delivery/DeliveryController.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery; 2 | 3 | 4 | import lombok.AllArgsConstructor; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.web.bind.annotation.*; 7 | 8 | import javax.validation.Valid; 9 | 10 | @RestController 11 | @RequestMapping("deliveries") 12 | @AllArgsConstructor 13 | public class DeliveryController { 14 | 15 | 16 | private DeliveryService deliveryService; 17 | 18 | @RequestMapping(method = RequestMethod.POST) 19 | @ResponseStatus(value = HttpStatus.CREATED) 20 | public DeliveryDto.Res create(@RequestBody @Valid final DeliveryDto.CreationReq dto) { 21 | return new DeliveryDto.Res(deliveryService.create(dto)); 22 | } 23 | 24 | @RequestMapping(value = "/{id}", method = RequestMethod.GET) 25 | @ResponseStatus(value = HttpStatus.OK) 26 | public DeliveryDto.Res getDelivery(@PathVariable final long id) { 27 | return new DeliveryDto.Res(deliveryService.findById(id)); 28 | } 29 | 30 | 31 | @RequestMapping(value = "/{id}/logs", method = RequestMethod.POST) 32 | @ResponseStatus(value = HttpStatus.OK) 33 | public DeliveryDto.Res updateDelivery(@PathVariable final long id, @RequestBody DeliveryDto.UpdateReq dto) { 34 | return new DeliveryDto.Res(deliveryService.updateStatus(id, dto)); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/delivery/DeliveryDto.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery; 2 | 3 | import com.cheese.springjpa.Account.domain.Address; 4 | import com.cheese.springjpa.common.model.DateTime; 5 | import java.util.List; 6 | import java.util.stream.Collectors; 7 | import javax.validation.Valid; 8 | import lombok.AccessLevel; 9 | import lombok.Builder; 10 | import lombok.Getter; 11 | import lombok.NoArgsConstructor; 12 | 13 | public class DeliveryDto { 14 | 15 | 16 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 17 | @Getter 18 | public static class CreationReq { 19 | @Valid 20 | private Address address; 21 | 22 | @Builder 23 | public CreationReq(Address address) { 24 | this.address = address; 25 | } 26 | 27 | public Delivery toEntity() { 28 | return Delivery.builder() 29 | .address(address) 30 | .build(); 31 | } 32 | } 33 | 34 | @Getter 35 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 36 | public static class UpdateReq { 37 | private DeliveryStatus status; 38 | 39 | @Builder 40 | public UpdateReq(DeliveryStatus status) { 41 | this.status = status; 42 | } 43 | 44 | } 45 | 46 | @Getter 47 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 48 | public static class Res { 49 | private Address address; 50 | private List logs; 51 | 52 | public Res(final Delivery delivery) { 53 | this.address = delivery.getAddress(); 54 | this.logs = delivery.getLogs() 55 | .parallelStream().map(LogRes::new) 56 | .collect(Collectors.toList()); 57 | } 58 | } 59 | 60 | 61 | @Getter 62 | public static class LogRes { 63 | private DeliveryStatus status; 64 | private DateTime dateTime; 65 | 66 | public LogRes(DeliveryLog log) { 67 | this.status = log.getStatus(); 68 | this.dateTime = log.getDateTime(); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/delivery/DeliveryLog.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery; 2 | 3 | import com.cheese.springjpa.common.model.DateTime; 4 | import com.cheese.springjpa.delivery.exception.DeliveryAlreadyDeliveringException; 5 | import com.cheese.springjpa.delivery.exception.DeliveryStatusEqaulsException; 6 | import com.fasterxml.jackson.annotation.JsonProperty; 7 | import lombok.AccessLevel; 8 | import lombok.Builder; 9 | import lombok.Getter; 10 | import lombok.NoArgsConstructor; 11 | 12 | import javax.persistence.*; 13 | 14 | @Entity 15 | @Table(name = "delivery_log") 16 | @Getter 17 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 18 | public class DeliveryLog { 19 | 20 | @Id 21 | @GeneratedValue(strategy = GenerationType.AUTO) 22 | private long id; 23 | 24 | @Enumerated(EnumType.STRING) 25 | @Column(name = "status", nullable = false, updatable = false) 26 | private DeliveryStatus status; 27 | 28 | @Embedded 29 | private DateTime dateTime; 30 | 31 | 32 | @Transient 33 | private DeliveryStatus lastStatus; 34 | 35 | @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) 36 | @ManyToOne 37 | @JoinColumn(name = "delivery_id", nullable = false, updatable = false) 38 | private Delivery delivery; 39 | 40 | 41 | @Builder 42 | public DeliveryLog(final DeliveryStatus status, final Delivery delivery) { 43 | verifyStatus(status, delivery); 44 | setStatus(status); 45 | this.delivery = delivery; 46 | } 47 | 48 | private void verifyStatus(DeliveryStatus status, Delivery delivery) { 49 | if (!delivery.getLogs().isEmpty()) { 50 | lastStatus = getLastStatus(delivery); 51 | verifyLastStatusEquals(status); 52 | verifyAlreadyCompleted(); 53 | } 54 | } 55 | 56 | private DeliveryStatus getLastStatus(Delivery delivery) { 57 | final int lastIndex = delivery.getLogs().size() - 1; 58 | return delivery.getLogs().get(lastIndex).getStatus(); 59 | } 60 | 61 | private void setStatus(final DeliveryStatus status) { 62 | switch (status) { 63 | case DELIVERING: 64 | delivering(); 65 | break; 66 | case COMPLETED: 67 | completed(); 68 | break; 69 | case CANCELED: 70 | cancel(); 71 | break; 72 | case PENDING: 73 | pending(); 74 | break; 75 | default: 76 | throw new IllegalArgumentException(status.name() + " is not found"); 77 | } 78 | } 79 | 80 | 81 | private void pending() { 82 | this.status = DeliveryStatus.PENDING; 83 | } 84 | 85 | private void cancel() { 86 | verifyNotYetDelivering(); 87 | this.status = DeliveryStatus.CANCELED; 88 | } 89 | 90 | private void completed() { 91 | this.status = DeliveryStatus.COMPLETED; 92 | } 93 | 94 | private void delivering() { 95 | this.status = DeliveryStatus.DELIVERING; 96 | } 97 | 98 | private void verifyNotYetDelivering() { 99 | if (isNotYetDelivering()) throw new DeliveryAlreadyDeliveringException(); 100 | } 101 | 102 | private boolean isNotYetDelivering() { 103 | return this.lastStatus != DeliveryStatus.PENDING ; 104 | } 105 | 106 | private void verifyAlreadyCompleted() { 107 | if (isCompleted()) 108 | throw new IllegalArgumentException("It has already been completed and can not be changed."); 109 | } 110 | 111 | private void verifyLastStatusEquals(DeliveryStatus status) { 112 | if (lastStatus == status) throw new DeliveryStatusEqaulsException(lastStatus); 113 | } 114 | 115 | private boolean isCompleted() { 116 | return lastStatus == DeliveryStatus.COMPLETED; 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/delivery/DeliveryRepository.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | public interface DeliveryRepository extends JpaRepository { 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/delivery/DeliveryService.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery; 2 | 3 | import com.cheese.springjpa.delivery.exception.DeliveryNotFoundException; 4 | import lombok.AllArgsConstructor; 5 | import org.springframework.stereotype.Service; 6 | 7 | import javax.transaction.Transactional; 8 | import java.util.Optional; 9 | 10 | @Service 11 | @Transactional 12 | @AllArgsConstructor 13 | public class DeliveryService { 14 | 15 | private DeliveryRepository deliveryRepository; 16 | 17 | public Delivery create(DeliveryDto.CreationReq dto) { 18 | final Delivery delivery = dto.toEntity(); 19 | delivery.addLog(DeliveryStatus.PENDING); 20 | return deliveryRepository.save(delivery); 21 | } 22 | 23 | public Delivery updateStatus(long id, DeliveryDto.UpdateReq dto) { 24 | final Delivery delivery = findById(id); 25 | delivery.addLog(dto.getStatus()); 26 | return delivery; 27 | } 28 | 29 | 30 | public Delivery findById(long id) { 31 | final Optional delivery = deliveryRepository.findById(id); 32 | delivery.orElseThrow(() -> new DeliveryNotFoundException(id)); 33 | return delivery.get(); 34 | } 35 | 36 | public Delivery removeLogs(long id) { 37 | final Delivery delivery = findById(id); 38 | delivery.getLogs().clear(); 39 | return delivery; 40 | } 41 | 42 | public void remove(long id) { 43 | deliveryRepository.deleteById(id); 44 | } 45 | 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/delivery/DeliveryStatus.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery; 2 | 3 | public enum DeliveryStatus { 4 | CANCELED, 5 | PENDING, 6 | DELIVERING, 7 | COMPLETED 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/delivery/exception/DeliveryAlreadyDeliveringException.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery.exception; 2 | 3 | public class DeliveryAlreadyDeliveringException extends RuntimeException { 4 | } 5 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/delivery/exception/DeliveryNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery.exception; 2 | 3 | 4 | import lombok.Getter; 5 | 6 | @Getter 7 | public class DeliveryNotFoundException extends RuntimeException { 8 | 9 | private long id; 10 | 11 | public DeliveryNotFoundException(long id) { 12 | super(id + " is not found"); 13 | this.id = id; 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/delivery/exception/DeliveryStatusEqaulsException.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery.exception; 2 | 3 | import com.cheese.springjpa.delivery.DeliveryStatus; 4 | 5 | public class DeliveryStatusEqaulsException extends RuntimeException { 6 | 7 | 8 | private DeliveryStatus status; 9 | 10 | public DeliveryStatusEqaulsException(DeliveryStatus status) { 11 | super(status.name() + " It can not be changed to the same state."); 12 | this.status = status; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/error/ErrorCode.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.error; 2 | 3 | import lombok.Getter; 4 | 5 | @Getter 6 | public enum ErrorCode { 7 | 8 | ACCOUNT_NOT_FOUND("AC_001", "해당 회원을 찾을 수 없습니다.", 404), 9 | EMAIL_DUPLICATION("AC_001", "이메일이 중복되었습니다.", 400), 10 | INPUT_VALUE_INVALID("???", "입력값이 올바르지 않습니다.", 400), 11 | PASSWORD_FAILED_EXCEEDED("???", "비밀번호 실패 횟수가 초과했습니다.", 400); 12 | 13 | 14 | private final String code; 15 | private final String message; 16 | private final int status; 17 | 18 | ErrorCode(String code, String message, int status) { 19 | this.code = code; 20 | this.message = message; 21 | this.status = status; 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/error/ErrorExceptionController.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.error; 2 | 3 | import com.cheese.springjpa.Account.exception.AccountNotFoundException; 4 | import com.cheese.springjpa.Account.exception.EmailDuplicationException; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.dao.DataIntegrityViolationException; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.validation.BindException; 9 | import org.springframework.validation.BindingResult; 10 | import org.springframework.validation.FieldError; 11 | import org.springframework.web.bind.MethodArgumentNotValidException; 12 | import org.springframework.web.bind.annotation.ControllerAdvice; 13 | import org.springframework.web.bind.annotation.ExceptionHandler; 14 | import org.springframework.web.bind.annotation.ResponseBody; 15 | import org.springframework.web.bind.annotation.ResponseStatus; 16 | 17 | import java.util.List; 18 | import java.util.stream.Collectors; 19 | 20 | @ControllerAdvice 21 | @ResponseBody 22 | @Slf4j 23 | public class ErrorExceptionController { 24 | 25 | @ExceptionHandler(value = { 26 | AccountNotFoundException.class 27 | }) 28 | @ResponseStatus(HttpStatus.NOT_FOUND) 29 | protected ErrorResponse handleAccountNotFoundException(AccountNotFoundException e) { 30 | final ErrorCode accountNotFound = ErrorCode.ACCOUNT_NOT_FOUND; 31 | log.error(accountNotFound.getMessage(), e.getId()); 32 | return buildError(accountNotFound); 33 | } 34 | 35 | @ExceptionHandler(MethodArgumentNotValidException.class) 36 | @ResponseStatus(HttpStatus.BAD_REQUEST) 37 | protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { 38 | final List fieldErrors = getFieldErrors(e.getBindingResult()); 39 | return buildFieldErrors(ErrorCode.INPUT_VALUE_INVALID, fieldErrors); 40 | } 41 | 42 | @ExceptionHandler(BindException.class) 43 | @ResponseStatus(HttpStatus.BAD_REQUEST) 44 | protected ErrorResponse handleBindException(BindException e) { 45 | final List fieldErrors = getFieldErrors(e.getBindingResult()); 46 | return buildFieldErrors(ErrorCode.INPUT_VALUE_INVALID, fieldErrors); 47 | } 48 | 49 | 50 | @ExceptionHandler(EmailDuplicationException.class) 51 | @ResponseStatus(HttpStatus.BAD_REQUEST) 52 | protected ErrorResponse handleConstraintViolationException(EmailDuplicationException e) { 53 | final ErrorCode errorCode = ErrorCode.EMAIL_DUPLICATION; 54 | log.error(errorCode.getMessage(), e.getEmail() + e.getField()); 55 | return buildError(errorCode); 56 | } 57 | 58 | @ExceptionHandler(DataIntegrityViolationException.class) 59 | @ResponseStatus(HttpStatus.BAD_REQUEST) 60 | protected ErrorResponse handleDataIntegrityViolationException(DataIntegrityViolationException e) { 61 | log.error(e.getMessage()); 62 | return buildError(ErrorCode.INPUT_VALUE_INVALID); 63 | } 64 | 65 | 66 | // TODO: 2018. 5. 12. 비밀번호 변경 컨트롤러 생성시 주석 해제할것 -yun 67 | // @ExceptionHandler(PasswordFailedExceededException.class) 68 | // @ResponseStatus(HttpStatus.BAD_REQUEST) 69 | // protected ErrorResponse handlePasswordFailedExceededException(PasswordFailedExceededException e) { 70 | // log.error(e.getMessage()); 71 | // return buildError(e.getErrorCode()); 72 | // } 73 | 74 | private List getFieldErrors(BindingResult bindingResult) { 75 | final List errors = bindingResult.getFieldErrors(); 76 | return errors.parallelStream() 77 | .map(error -> ErrorResponse.FieldError.builder() 78 | .reason(error.getDefaultMessage()) 79 | .field(error.getField()) 80 | .value((String) error.getRejectedValue()) 81 | .build()) 82 | .collect(Collectors.toList()); 83 | } 84 | 85 | 86 | private ErrorResponse buildError(ErrorCode errorCode) { 87 | return ErrorResponse.builder() 88 | .code(errorCode.getCode()) 89 | .status(errorCode.getStatus()) 90 | .message(errorCode.getMessage()) 91 | .build(); 92 | } 93 | 94 | private ErrorResponse buildFieldErrors(ErrorCode errorCode, List errors) { 95 | return ErrorResponse.builder() 96 | .code(errorCode.getCode()) 97 | .status(errorCode.getStatus()) 98 | .message(errorCode.getMessage()) 99 | .errors(errors) 100 | .build(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/error/ErrorResponse.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.error; 2 | 3 | import lombok.Builder; 4 | import lombok.Getter; 5 | 6 | import java.util.ArrayList; 7 | import java.util.List; 8 | 9 | @Getter 10 | public class ErrorResponse { 11 | 12 | private String message; 13 | private String code; 14 | private int status; 15 | private List errors = new ArrayList<>(); 16 | 17 | @Builder 18 | public ErrorResponse(String message, String code, int status, List errors) { 19 | this.message = message; 20 | this.code = code; 21 | this.status = status; 22 | this.errors = initErrors(errors); 23 | } 24 | 25 | private List initErrors(List errors) { 26 | return (errors == null) ? new ArrayList<>() : errors; 27 | } 28 | 29 | @Getter 30 | public static class FieldError { 31 | private String field; 32 | private String value; 33 | private String reason; 34 | 35 | @Builder 36 | public FieldError(String field, String value, String reason) { 37 | this.field = field; 38 | this.value = value; 39 | this.reason = reason; 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/order/Order.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.order; 2 | 3 | import com.cheese.springjpa.coupon.Coupon; 4 | import lombok.AccessLevel; 5 | import lombok.Builder; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | 9 | import javax.persistence.*; 10 | 11 | @Entity 12 | @Table(name = "orders") 13 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 14 | @Getter 15 | public class Order { 16 | 17 | @Id 18 | @GeneratedValue 19 | private long id; 20 | 21 | @Column(name = "price") 22 | private double price; 23 | 24 | @OneToOne 25 | @JoinColumn(name = "coupon_id", referencedColumnName = "id", nullable = false) 26 | private Coupon coupon; 27 | 28 | // @OneToOne(mappedBy = "order") 29 | // private Coupon coupon; 30 | 31 | @Builder 32 | public Order(double price) { 33 | this.price = price; 34 | } 35 | 36 | public void applyCoupon(final Coupon coupon) { 37 | this.coupon = coupon; 38 | coupon.use(this); 39 | price -= coupon.getDiscountAmount(); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/order/OrderController.java: -------------------------------------------------------------------------------- 1 | //package com.cheese.springjpa.order; 2 | // 3 | //import com.cheese.springjpa.coupon.Coupon; 4 | //import com.cheese.springjpa.coupon.CouponService; 5 | //import lombok.AllArgsConstructor; 6 | //import org.springframework.web.bind.annotation.PathVariable; 7 | //import org.springframework.web.bind.annotation.RequestMapping; 8 | //import org.springframework.web.bind.annotation.RequestMethod; 9 | //import org.springframework.web.bind.annotation.RestController; 10 | // 11 | //@RestController 12 | //@RequestMapping("/orders") 13 | //@AllArgsConstructor 14 | //public class OrderController { 15 | // 16 | // 17 | // private final OrderService orderService; 18 | // private final CouponService couponService; 19 | // 20 | // @RequestMapping(value = "{id}", method = RequestMethod.GET) 21 | // public Order getOrders(@PathVariable("id") long id) { 22 | // return orderService.findById(id); 23 | // } 24 | // 25 | // @RequestMapping(method = RequestMethod.GET) 26 | // public Order getOrders() { 27 | // return orderService.order(); 28 | // } 29 | // 30 | // @RequestMapping(value = "coupons/{id}", method = RequestMethod.GET) 31 | // public Coupon getCoupon(@PathVariable("id") long id) { 32 | // return couponService.findById(id); 33 | // } 34 | // 35 | // 36 | //} 37 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/order/OrderRepository.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.order; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | public interface OrderRepository extends JpaRepository { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/order/OrderService.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.order; 2 | 3 | import com.cheese.springjpa.coupon.Coupon; 4 | import com.cheese.springjpa.coupon.CouponService; 5 | import lombok.AllArgsConstructor; 6 | import org.springframework.stereotype.Service; 7 | import org.springframework.transaction.annotation.Transactional; 8 | 9 | @Service 10 | @Transactional 11 | @AllArgsConstructor 12 | public class OrderService { 13 | 14 | private final OrderRepository orderRepository; 15 | private final CouponService couponService; 16 | 17 | 18 | public Order order() { 19 | final Order order = Order.builder().price(1_0000).build(); // 10,000 상품주문 20 | Coupon coupon = couponService.findById(1); // 1,000 할인 쿠폰 21 | order.applyCoupon(coupon); 22 | return orderRepository.save(order); 23 | } 24 | 25 | public Order findById(long id) { 26 | return orderRepository.findById(id).get(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/properties/AntiSamplePropertiesRunner.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.properties; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.boot.ApplicationArguments; 6 | import org.springframework.boot.ApplicationRunner; 7 | import org.springframework.core.env.Environment; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Component 11 | @AllArgsConstructor 12 | @Slf4j 13 | public class AntiSamplePropertiesRunner implements ApplicationRunner { 14 | 15 | private final Environment env; 16 | 17 | @Override 18 | public void run(ApplicationArguments args) { 19 | final String email = env.getProperty("user.email"); 20 | final String nickname = env.getProperty("user.nickname"); 21 | final int age = Integer.valueOf(env.getProperty("user.age")); 22 | final boolean auth = Boolean.valueOf(env.getProperty("user.auth")); 23 | final int amount = Integer.valueOf(env.getProperty("user.amount")); 24 | 25 | 26 | log.info("=========ANTI========="); 27 | log.info(email); 28 | log.info(nickname); 29 | log.info(String.valueOf(age)); 30 | log.info(String.valueOf(auth)); 31 | log.info(String.valueOf(amount)); 32 | log.info("=========ANTI========="); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/properties/SampleProperties.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.properties; 2 | 3 | import lombok.Getter; 4 | import lombok.Setter; 5 | import org.hibernate.validator.constraints.Email; 6 | import org.hibernate.validator.constraints.NotEmpty; 7 | import org.springframework.boot.context.properties.ConfigurationProperties; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.validation.annotation.Validated; 10 | 11 | @Configuration 12 | @ConfigurationProperties(prefix = "user") 13 | @Validated 14 | @Getter 15 | @Setter 16 | public class SampleProperties { 17 | @Email 18 | private String email; 19 | 20 | @NotEmpty 21 | private String nickname; 22 | 23 | private int age; 24 | 25 | private boolean auth; 26 | 27 | private double amount; 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/cheese/springjpa/properties/SamplePropertiesRunner.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.properties; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.boot.ApplicationArguments; 6 | import org.springframework.boot.ApplicationRunner; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Component 10 | @AllArgsConstructor 11 | @Slf4j 12 | public class SamplePropertiesRunner implements ApplicationRunner { 13 | 14 | private final SampleProperties properties; 15 | 16 | @Override 17 | public void run(ApplicationArguments args) { 18 | final String email = properties.getEmail(); 19 | final String name = properties.getNickname(); 20 | final int age = properties.getAge(); 21 | final boolean auth = properties.isAuth(); 22 | final double amount = properties.getAmount(); 23 | 24 | log.info("=================="); 25 | log.info(email); 26 | log.info(name); 27 | log.info(String.valueOf(age)); 28 | log.info(String.valueOf(auth)); 29 | log.info(String.valueOf(amount)); 30 | log.info("=================="); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/resources/application-dev.yml: -------------------------------------------------------------------------------- 1 | user: 2 | email: "yun@test" 3 | nickname: "nickname" 4 | age: 28 5 | auth: false 6 | amount: 101 7 | 8 | spring: 9 | 10 | jpa: 11 | database: h2 12 | hibernate: 13 | ddl-auto: create-drop 14 | show-sql: true 15 | datasource: 16 | data: classpath:init.sql # 시작할때 실행시킬 script 17 | 18 | 19 | jackson: 20 | serialization: 21 | WRITE_DATES_AS_TIMESTAMPS: false 22 | 23 | logging: 24 | level: 25 | ROOT: info 26 | -------------------------------------------------------------------------------- /src/main/resources/application-local.yml: -------------------------------------------------------------------------------- 1 | user: 2 | email: "yun@test" 3 | nickname: "nickname" 4 | age: 28 5 | auth: false 6 | amount: 101 7 | 8 | spring: 9 | jpa: 10 | database: h2 11 | hibernate: 12 | ddl-auto: create-drop 13 | show-sql: true 14 | properties: 15 | hibernate.format_sql: true 16 | datasource: 17 | data: classpath:init.sql # 시작할때 실행시킬 script 18 | 19 | 20 | jackson: 21 | serialization: 22 | WRITE_DATES_AS_TIMESTAMPS: false 23 | 24 | logging: 25 | level: 26 | root: info 27 | org: 28 | hibernate: 29 | type: trace 30 | -------------------------------------------------------------------------------- /src/main/resources/application-prod.yml: -------------------------------------------------------------------------------- 1 | user: 2 | email: "yun@test" 3 | nickname: "nickname" 4 | age: 28 5 | auth: false 6 | amount: 101 7 | 8 | spring: 9 | 10 | jpa: 11 | database: h2 12 | hibernate: 13 | ddl-auto: create-drop 14 | show-sql: true 15 | datasource: 16 | data: classpath:init.sql # 시작할때 실행시킬 script 17 | 18 | 19 | jackson: 20 | serialization: 21 | WRITE_DATES_AS_TIMESTAMPS: false 22 | 23 | logging: 24 | level: 25 | ROOT: info 26 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: 3 | active: local 4 | 5 | server: 6 | port: 8080 7 | -------------------------------------------------------------------------------- /src/main/resources/init.sql: -------------------------------------------------------------------------------- 1 | insert into coupon 2 | (id, discount_amount, use) 3 | values 4 | (1, 1000, false), 5 | (2, 1000, false), 6 | (3, 1000, false), 7 | (4, 1000, false); 8 | 9 | insert into orders 10 | (id, price, coupon_id) 11 | values 12 | (2, 20000, 2), 13 | (3, 30000, 3), 14 | (4, 40000, 4); 15 | 16 | insert into account 17 | (address1, address2, zip, created_at, update_at, email, first_name, last_name, password_expiration_date, password_failed_count, password_ttl, password, id) 18 | values 19 | ('address1', 'address2', '002', '2019-01-20 00:00:01', '2019-01-20 00:00:00', 'test001@test.com', 'yun', 'jun', '20120-01-20 00:00:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 1), 20 | ('address1', 'address2', '002', '2019-01-20 00:00:02', '2019-01-20 00:00:00', 'test002@test.com', 'kim', 'pool', '20120-01-20 00:01:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 2), 21 | ('address1', 'address2', '002', '2019-01-20 00:00:03', '2019-01-20 00:00:00', 'test003@test.com', 'chan', 'kim', '20120-01-20 00:02:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 3), 22 | ('address1', 'address2', '002', '2019-01-20 00:00:04', '2019-01-20 00:00:00', 'test004@test.com', 'legend', 'fifo', '20120-01-20 00:03:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 4), 23 | ('address1', 'address2', '002', '2019-01-20 00:00:05', '2019-01-20 00:00:00', 'test005@test.com', 'aws', 'filo', '20120-01-20 00:04:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 5), 24 | ('address1', 'address2', '002', '2019-01-20 00:00:06', '2019-01-20 00:00:00', 'test006@test.com', 'orm', 'log', '20120-01-20 00:05:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 6), 25 | ('address1', 'address2', '002', '2019-01-20 00:00:07', '2019-01-20 00:00:00', 'test007@test.com', 'jpa', 'mvc', '20120-01-20 00:06:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 7), 26 | ('address1', 'address2', '002', '2019-01-20 00:00:08', '2019-01-20 00:00:00', 'test008@test.com', 'yun', 'builder', '20120-01-20 00:07:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 8), 27 | ('address1', 'address2', '002', '2019-01-20 00:00:09', '2019-01-20 00:00:00', 'test009@test.com', 'for', 'template', '20120-01-20 00:08:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 9), 28 | ('address1', 'address2', '002', '2019-01-20 00:00:10', '2019-01-20 00:00:00', 'test010@test.com', 'php', 'intellij', '20120-01-20 00:09:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 10), 29 | ('address1', 'address2', '002', '2019-01-20 00:00:11', '2019-01-20 00:00:00', 'test011@test.com', 'javascript', 'kim', '20120-01-20 00:10:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 11), 30 | ('address1', 'address2', '002', '2019-01-20 00:00:12', '2019-01-20 00:00:00', 'test012@test.com', 'spring', 'kim', '20120-01-20 00:11:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 12), 31 | ('address1', 'address2', '002', '2019-01-20 00:00:13', '2019-01-20 00:00:00', 'test013@test.com', 'org', 'kim', '20120-01-20 00:12:00', 0, 1209604, '$2a$10$tI3Y.nhgC.73LYCszoCaLu3nNEIM4QgeACiNseWlvr1zjrV5NCCs6', 13); -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/Account/AccountFindServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account; 2 | 3 | import static org.assertj.core.api.Java6Assertions.assertThat; 4 | import static org.mockito.ArgumentMatchers.any; 5 | import static org.mockito.BDDMockito.given; 6 | 7 | import com.cheese.springjpa.Account.dao.AccountFindService; 8 | import com.cheese.springjpa.Account.dao.AccountRepository; 9 | import com.cheese.springjpa.Account.domain.Account; 10 | import com.cheese.springjpa.Account.domain.Email; 11 | import com.cheese.springjpa.Account.exception.AccountNotFoundException; 12 | import java.util.Optional; 13 | import org.junit.Before; 14 | import org.junit.Test; 15 | import org.junit.runner.RunWith; 16 | import org.mockito.InjectMocks; 17 | import org.mockito.Mock; 18 | import org.mockito.junit.MockitoJUnitRunner; 19 | 20 | @RunWith(MockitoJUnitRunner.class) 21 | public class AccountFindServiceTest { 22 | 23 | @InjectMocks 24 | private AccountFindService accountFindService; 25 | 26 | @Mock 27 | private AccountRepository accountRepository; 28 | private Account account; 29 | private Email email; 30 | 31 | @Before 32 | public void setUp() throws Exception { 33 | email = Email.of("yun@asd.com"); 34 | account = Account.builder() 35 | .email(email) 36 | .build(); 37 | } 38 | 39 | @Test 40 | public void findById_존재하는경우() { 41 | //given 42 | given(accountRepository.findById(any())).willReturn(Optional.of(account)); 43 | 44 | //when 45 | final Account findAccount = accountFindService.findById(1L); 46 | 47 | //then 48 | assertThat(findAccount.getEmail().getValue()).isEqualTo(account.getEmail().getValue()); 49 | } 50 | 51 | @Test(expected = AccountNotFoundException.class) 52 | public void findById_없는경우() { 53 | //given 54 | given(accountRepository.findById(any())).willReturn(Optional.empty()); 55 | 56 | //when 57 | accountFindService.findById(1L); 58 | 59 | //then 60 | } 61 | 62 | @Test 63 | public void findByEmail_존재하는경우() { 64 | //given 65 | given(accountRepository.findByEmail(any())).willReturn(account); 66 | 67 | //when 68 | final Account findAccount = accountFindService.findByEmail(email); 69 | 70 | //then 71 | assertThat(findAccount.getEmail().getValue()).isEqualTo(account.getEmail().getValue()); 72 | } 73 | 74 | @Test(expected = AccountNotFoundException.class) 75 | public void findByEmail_없는경우() { 76 | //given 77 | 78 | given(accountRepository.findByEmail(any())).willReturn(null); 79 | 80 | //when 81 | accountFindService.findByEmail(email); 82 | 83 | //then 84 | } 85 | 86 | @Test 87 | public void isExistedEmail_이메일_있으면_true() { 88 | //given 89 | given(accountRepository.existsByEmail(email)).willReturn(true); 90 | 91 | //when 92 | final boolean existedEmail = accountFindService.isExistedEmail(email); 93 | 94 | //then 95 | assertThat(existedEmail).isTrue(); 96 | } 97 | 98 | @Test 99 | public void isExistedEmail_이메일_없으면_false() { 100 | //given 101 | given(accountRepository.existsByEmail(email)).willReturn(false); 102 | 103 | //when 104 | final boolean existedEmail = accountFindService.isExistedEmail(email); 105 | 106 | //then 107 | assertThat(existedEmail).isFalse(); 108 | } 109 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/Account/AccountIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 5 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 6 | 7 | import com.cheese.springjpa.Account.dto.AccountSearchType; 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | import org.springframework.http.MediaType; 14 | import org.springframework.test.context.ActiveProfiles; 15 | import org.springframework.test.context.junit4.SpringRunner; 16 | import org.springframework.test.web.servlet.MockMvc; 17 | import org.springframework.test.web.servlet.ResultActions; 18 | 19 | @RunWith(SpringRunner.class) 20 | @SpringBootTest 21 | @AutoConfigureMockMvc 22 | @ActiveProfiles("local") 23 | public class AccountIntegrationTest { 24 | 25 | @Autowired 26 | private MockMvc mvc; 27 | 28 | @Test 29 | public void account_name_조회() throws Exception { 30 | final AccountSearchType type = AccountSearchType.NAME; 31 | final String value = "yun"; 32 | 33 | final ResultActions resultActions = requestSearchPaging(type, value); 34 | 35 | resultActions 36 | .andExpect(status().isOk()) 37 | ; 38 | } 39 | 40 | @Test 41 | public void account_email_조회() throws Exception { 42 | final AccountSearchType type = AccountSearchType.EMAIL; 43 | final String value = "test"; 44 | 45 | final ResultActions resultActions = requestSearchPaging(type, value); 46 | 47 | resultActions 48 | .andExpect(status().isOk()) 49 | ; 50 | } 51 | 52 | @Test 53 | public void account_ALL_조회() throws Exception { 54 | final AccountSearchType type = AccountSearchType.ALL; 55 | final String value = ""; 56 | 57 | final ResultActions resultActions = requestSearchPaging(type, value); 58 | 59 | resultActions 60 | .andExpect(status().isOk()) 61 | ; 62 | } 63 | 64 | private ResultActions requestSearchPaging(AccountSearchType type, String value) throws Exception { 65 | return mvc.perform(get("/accounts") 66 | .contentType(MediaType.APPLICATION_JSON) 67 | .param("page", "1") 68 | .param("size", "20") 69 | .param("direction", "ASC") 70 | .param("type", type.name()) 71 | .param("value", value)) 72 | .andDo(print()); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/Account/AccountRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account; 2 | 3 | import static org.assertj.core.api.Java6Assertions.assertThat; 4 | 5 | import com.cheese.springjpa.Account.dao.AccountRepository; 6 | import com.cheese.springjpa.Account.domain.Account; 7 | import com.cheese.springjpa.Account.domain.Email; 8 | import com.cheese.springjpa.Account.domain.QAccount; 9 | import com.querydsl.core.types.Predicate; 10 | import java.util.List; 11 | import java.util.Optional; 12 | import org.junit.Test; 13 | import org.junit.runner.RunWith; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 16 | import org.springframework.test.context.junit4.SpringRunner; 17 | 18 | 19 | @DataJpaTest 20 | @RunWith(SpringRunner.class) 21 | public class AccountRepositoryTest { 22 | 23 | @Autowired 24 | private AccountRepository accountRepository; 25 | 26 | private final QAccount qAccount = QAccount.account; 27 | 28 | @Test 29 | public void findByEmail_test() { 30 | final String email = "test001@test.com"; 31 | final Account account = accountRepository.findByEmail(Email.of(email)); 32 | assertThat(account.getEmail().getValue()).isEqualTo(email); 33 | } 34 | 35 | @Test 36 | public void findById_test() { 37 | final Optional optionalAccount = accountRepository.findById(1L); 38 | final Account account = optionalAccount.get(); 39 | assertThat(account.getId()).isEqualTo(1L); 40 | } 41 | 42 | @Test 43 | public void isExistedEmail_test() { 44 | final String email = "test001@test.com"; 45 | final boolean existsByEmail = accountRepository.existsByEmail(Email.of(email)); 46 | assertThat(existsByEmail).isTrue(); 47 | } 48 | 49 | @Test 50 | public void findRecentlyRegistered_test() { 51 | final List accounts = accountRepository.findRecentlyRegistered(10); 52 | assertThat(accounts.size()).isLessThan(11); 53 | } 54 | 55 | @Test 56 | public void predicate_test_001() { 57 | //given 58 | final Predicate predicate = qAccount.email.eq(Email.of("test001@test.com")); 59 | 60 | //when 61 | final boolean exists = accountRepository.exists(predicate); 62 | 63 | //then 64 | assertThat(exists).isTrue(); 65 | } 66 | 67 | @Test 68 | public void predicate_test_002() { 69 | //given 70 | final Predicate predicate = qAccount.firstName.eq("test"); 71 | 72 | //when 73 | final boolean exists = accountRepository.exists(predicate); 74 | 75 | //then 76 | assertThat(exists).isFalse(); 77 | } 78 | 79 | @Test 80 | public void predicate_test_003() { 81 | //given 82 | final Predicate predicate = qAccount.email.value.like("test%"); 83 | 84 | //when 85 | final long count = accountRepository.count(predicate); 86 | 87 | //then 88 | assertThat(count).isGreaterThan(1); 89 | } 90 | 91 | 92 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/Account/AccountServiceJUnit5Test.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account; 2 | 3 | import static org.hamcrest.CoreMatchers.is; 4 | import static org.junit.Assert.assertThat; 5 | import static org.mockito.ArgumentMatchers.anyLong; 6 | import static org.mockito.BDDMockito.given; 7 | import static org.mockito.Mockito.atLeastOnce; 8 | import static org.mockito.Mockito.verify; 9 | 10 | import com.cheese.springjpa.Account.application.AccountService; 11 | import com.cheese.springjpa.Account.dao.AccountRepository; 12 | import com.cheese.springjpa.Account.domain.Account; 13 | import com.cheese.springjpa.Account.domain.Address; 14 | import com.cheese.springjpa.Account.domain.Email; 15 | import com.cheese.springjpa.Account.dto.AccountDto; 16 | import java.util.Optional; 17 | import org.junit.jupiter.api.DisplayName; 18 | import org.junit.jupiter.api.Test; 19 | import org.junit.jupiter.api.extension.ExtendWith; 20 | import org.mockito.InjectMocks; 21 | import org.mockito.Mock; 22 | import org.mockito.junit.jupiter.MockitoExtension; 23 | 24 | 25 | @ExtendWith(MockitoExtension.class) 26 | public class AccountServiceJUnit5Test { 27 | 28 | 29 | @InjectMocks 30 | private AccountService accountService; 31 | 32 | @Mock 33 | private AccountRepository accountRepository; 34 | 35 | @Test 36 | @DisplayName("findById_존재하는경우_회원리턴") 37 | public void findBy_not_existed_test() { 38 | //given 39 | final AccountDto.SignUpReq dto = buildSignUpReq(); 40 | given(accountRepository.findById(anyLong())).willReturn(Optional.of(dto.toEntity())); 41 | 42 | //when 43 | final Account account = accountService.findById(anyLong()); 44 | 45 | //then 46 | verify(accountRepository, atLeastOnce()).findById(anyLong()); 47 | assertThatEqual(dto, account); 48 | } 49 | 50 | private void assertThatEqual(AccountDto.SignUpReq signUpReq, Account account) { 51 | assertThat(signUpReq.getAddress().getAddress1(), is(account.getAddress().getAddress1())); 52 | assertThat(signUpReq.getAddress().getAddress2(), is(account.getAddress().getAddress2())); 53 | assertThat(signUpReq.getAddress().getZip(), is(account.getAddress().getZip())); 54 | assertThat(signUpReq.getEmail(), is(account.getEmail())); 55 | assertThat(signUpReq.getFistName(), is(account.getFirstName())); 56 | assertThat(signUpReq.getLastName(), is(account.getLastName())); 57 | } 58 | 59 | private AccountDto.SignUpReq buildSignUpReq() { 60 | return AccountDto.SignUpReq.builder() 61 | .address(buildAddress("서울", "성동구", "052-2344")) 62 | .email(buildEmail("email")) 63 | .fistName("남윤") 64 | .lastName("김") 65 | .password("password111") 66 | .build(); 67 | } 68 | 69 | private Email buildEmail(final String email) { 70 | return Email.builder().value(email).build(); 71 | } 72 | 73 | private Address buildAddress(String address1, String address2, String zip) { 74 | return Address.builder() 75 | .address1(address1) 76 | .address2(address2) 77 | .zip(zip) 78 | .build(); 79 | 80 | } 81 | 82 | 83 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/SpringJpaApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | import org.springframework.test.context.junit4.SpringRunner; 7 | 8 | @RunWith(SpringRunner.class) 9 | @SpringBootTest 10 | public class SpringJpaApplicationTests { 11 | 12 | @Test 13 | public void contextLoads() { 14 | SpringJpaApplication.main(new String[]{}); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/account/AccountServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account; 2 | 3 | import static org.hamcrest.CoreMatchers.is; 4 | import static org.junit.Assert.assertThat; 5 | import static org.mockito.ArgumentMatchers.any; 6 | import static org.mockito.ArgumentMatchers.anyLong; 7 | import static org.mockito.BDDMockito.given; 8 | import static org.mockito.Mockito.atLeastOnce; 9 | import static org.mockito.Mockito.verify; 10 | 11 | import com.cheese.springjpa.Account.application.AccountService; 12 | import com.cheese.springjpa.Account.dao.AccountRepository; 13 | import com.cheese.springjpa.Account.domain.Account; 14 | import com.cheese.springjpa.Account.domain.Address; 15 | import com.cheese.springjpa.Account.domain.Email; 16 | import com.cheese.springjpa.Account.dto.AccountDto; 17 | import com.cheese.springjpa.Account.exception.AccountNotFoundException; 18 | import com.cheese.springjpa.Account.exception.EmailDuplicationException; 19 | import java.util.Optional; 20 | import org.junit.Test; 21 | import org.junit.runner.RunWith; 22 | import org.mockito.InjectMocks; 23 | import org.mockito.Mock; 24 | import org.mockito.junit.MockitoJUnitRunner; 25 | 26 | @RunWith(MockitoJUnitRunner.class) 27 | public class AccountServiceTest { 28 | 29 | @InjectMocks 30 | private AccountService accountService; 31 | 32 | @Mock 33 | private AccountRepository accountRepository; 34 | 35 | @Test 36 | public void create_회원가입_성공() { 37 | //given 38 | final AccountDto.SignUpReq dto = buildSignUpReq(); 39 | given(accountRepository.save(any(Account.class))).willReturn(dto.toEntity()); 40 | 41 | //when 42 | final Account account = accountService.create(dto); 43 | 44 | //then 45 | verify(accountRepository, atLeastOnce()).save(any(Account.class)); 46 | assertThatEqual(dto, account); 47 | 48 | //커버리지를 높이기 위한 임시 함수 49 | account.getId(); 50 | account.getCreatedAt(); 51 | account.getUpdatedAt(); 52 | } 53 | 54 | @Test(expected = EmailDuplicationException.class) 55 | public void create_중복된_이메일_경우_EmailDuplicationException() { 56 | //given 57 | final AccountDto.SignUpReq dto = buildSignUpReq(); 58 | given(accountRepository.findByEmail(any())).willReturn(dto.toEntity()); 59 | 60 | //when 61 | accountService.create(dto); 62 | } 63 | 64 | @Test 65 | public void findById_존재하는경우_회원리턴() { 66 | //given 67 | final AccountDto.SignUpReq dto = buildSignUpReq(); 68 | given(accountRepository.findById(anyLong())).willReturn(Optional.of(dto.toEntity())); 69 | 70 | //when 71 | final Account account = accountService.findById(anyLong()); 72 | 73 | //then 74 | verify(accountRepository, atLeastOnce()).findById(anyLong()); 75 | assertThatEqual(dto, account); 76 | } 77 | 78 | @Test(expected = AccountNotFoundException.class) 79 | public void findById_존재하지않은경우_AccountNotFoundException() { 80 | //given 81 | given(accountRepository.findById(anyLong())).willReturn(Optional.empty()); 82 | 83 | //when 84 | accountService.findById(anyLong()); 85 | } 86 | 87 | @Test 88 | public void updateMyAccount() { 89 | //given 90 | final AccountDto.SignUpReq signUpReq = buildSignUpReq(); 91 | final AccountDto.MyAccountReq dto = buildMyAccountReq(); 92 | given(accountRepository.findById(anyLong())).willReturn(Optional.of(signUpReq.toEntity())); 93 | 94 | //when 95 | final Account account = accountService.updateMyAccount(anyLong(), dto); 96 | 97 | //then 98 | assertThat(dto.getAddress().getAddress1(), is(account.getAddress().getAddress1())); 99 | assertThat(dto.getAddress().getAddress2(), is(account.getAddress().getAddress2())); 100 | assertThat(dto.getAddress().getZip(), is(account.getAddress().getZip())); 101 | } 102 | 103 | @Test 104 | public void isExistedEmail_존재하는이메일_ReturnTrue() { 105 | //given 106 | final AccountDto.SignUpReq signUpReq = buildSignUpReq(); 107 | given(accountRepository.findByEmail(any())).willReturn(signUpReq.toEntity()); 108 | 109 | //when 110 | final boolean existedEmail = accountService.isExistedEmail(any()); 111 | 112 | //then 113 | verify(accountRepository, atLeastOnce()).findByEmail(any()); 114 | assertThat(existedEmail, is(true)); 115 | } 116 | 117 | @Test 118 | public void findByEmail_존재하는_이매일조해경우_해당유저리턴() { 119 | //given 120 | final Account account = buildSignUpReq().toEntity(); 121 | given(accountRepository.findByEmail(account.getEmail())).willReturn(account); 122 | 123 | //when 124 | final Account accountServiceByEmail = accountService.findByEmail(account.getEmail()); 125 | 126 | //then 127 | assertThat(accountServiceByEmail.getEmail(), is(account.getEmail())); 128 | } 129 | 130 | @Test(expected = AccountNotFoundException.class) 131 | public void findByEmail_존재하는_않는경우() { 132 | //given 133 | given(accountRepository.findByEmail(any())).willReturn(null); 134 | 135 | //when 136 | accountService.findByEmail(any()); 137 | 138 | } 139 | 140 | private AccountDto.MyAccountReq buildMyAccountReq() { 141 | return AccountDto.MyAccountReq.builder() 142 | .address(buildAddress("주소수정", "주소수정2", "061-233-444")) 143 | .build(); 144 | } 145 | 146 | private void assertThatEqual(AccountDto.SignUpReq signUpReq, Account account) { 147 | assertThat(signUpReq.getAddress().getAddress1(), is(account.getAddress().getAddress1())); 148 | assertThat(signUpReq.getAddress().getAddress2(), is(account.getAddress().getAddress2())); 149 | assertThat(signUpReq.getAddress().getZip(), is(account.getAddress().getZip())); 150 | assertThat(signUpReq.getEmail(), is(account.getEmail())); 151 | assertThat(signUpReq.getFistName(), is(account.getFirstName())); 152 | assertThat(signUpReq.getLastName(), is(account.getLastName())); 153 | } 154 | 155 | private AccountDto.SignUpReq buildSignUpReq() { 156 | return AccountDto.SignUpReq.builder() 157 | .address(buildAddress("서울", "성동구", "052-2344")) 158 | .email(buildEmail("email")) 159 | .fistName("남윤") 160 | .lastName("김") 161 | .password("password111") 162 | .build(); 163 | } 164 | 165 | private Email buildEmail(final String email) { 166 | return Email.builder().value(email).build(); 167 | } 168 | 169 | private Address buildAddress(String address1, String address2, String zip) { 170 | return Address.builder() 171 | .address1(address1) 172 | .address2(address2) 173 | .zip(zip) 174 | .build(); 175 | 176 | } 177 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/account/model/EmailTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.model; 2 | 3 | import static org.hamcrest.CoreMatchers.is; 4 | import static org.junit.Assert.assertThat; 5 | 6 | import com.cheese.springjpa.Account.domain.Email; 7 | import org.junit.Test; 8 | 9 | public class EmailTest { 10 | 11 | @Test 12 | public void email() { 13 | final String id = "test"; 14 | final String host = "@test.com"; 15 | final Email email = Email.builder() 16 | .value(id + host) 17 | .build(); 18 | 19 | assertThat(email.getHost(), is(host)); 20 | assertThat(email.getId(), is(id)); 21 | } 22 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/account/model/PasswordTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.Account.model; 2 | 3 | import static org.hamcrest.CoreMatchers.is; 4 | import static org.hamcrest.CoreMatchers.notNullValue; 5 | import static org.junit.Assert.assertThat; 6 | 7 | import com.cheese.springjpa.Account.domain.Password; 8 | import com.cheese.springjpa.Account.exception.PasswordFailedExceededException; 9 | import java.time.LocalDateTime; 10 | import org.junit.Before; 11 | import org.junit.Test; 12 | import org.junit.runner.RunWith; 13 | import org.mockito.junit.MockitoJUnitRunner; 14 | 15 | @RunWith(MockitoJUnitRunner.class) 16 | public class PasswordTest { 17 | 18 | 19 | private final long TTL = 1209_604L; 20 | private String passwordValue; 21 | private Password password; 22 | 23 | @Before 24 | public void setUp() { 25 | passwordValue = "password001"; 26 | password = Password.builder().value(passwordValue).build(); 27 | } 28 | 29 | @Test 30 | public void testPassword() { 31 | assertThat(password.isMatched(passwordValue), is(true)); 32 | assertThat(password.isExpiration(), is(false)); 33 | assertThat(password.getFailedCount(), is(0)); 34 | assertThat(password.getValue(), is(notNullValue())); 35 | assertThat(password.getExpirationDate(), is(notNullValue())); 36 | } 37 | 38 | @Test 39 | public void resetFailedCount_비밀번호가일치하는경우_실패카운트가초기화된다() { 40 | 41 | password.isMatched("notMatchedPassword"); 42 | password.isMatched("notMatchedPassword"); 43 | password.isMatched("notMatchedPassword"); 44 | 45 | assertThat(password.getFailedCount(), is(3)); 46 | 47 | password.isMatched(passwordValue); 48 | assertThat(password.getFailedCount(), is(0)); 49 | } 50 | 51 | @Test 52 | public void increaseFailCount_비밀번호가일치하지않는경우_실패카운트가증가한다() { 53 | password.isMatched("notMatchedPassword"); 54 | password.isMatched("notMatchedPassword"); 55 | password.isMatched("notMatchedPassword"); 56 | assertThat(password.getFailedCount(), is(3)); 57 | } 58 | 59 | @Test(expected = PasswordFailedExceededException.class) 60 | public void increaseFailCount_6회이상_PasswordFailedExceededException() { 61 | password.isMatched("notMatchedPassword"); 62 | password.isMatched("notMatchedPassword"); 63 | password.isMatched("notMatchedPassword"); 64 | password.isMatched("notMatchedPassword"); 65 | password.isMatched("notMatchedPassword"); 66 | 67 | password.isMatched("notMatchedPassword"); 68 | } 69 | 70 | @Test 71 | public void changePassword_비밀번호_일치하는경우_비밀번호가변경된다() { 72 | 73 | final String newPassword = "newPassword"; 74 | password.changePassword(newPassword, passwordValue); 75 | 76 | 77 | assertThat(password.isMatched(newPassword), is((true))); 78 | assertThat(password.getFailedCount(), is(0)); 79 | assertThat(password.getTtl(), is(TTL)); 80 | assertThat(password.getExpirationDate().isAfter(LocalDateTime.now().plusDays(14)), is(true)); 81 | 82 | } 83 | 84 | @Test 85 | public void changePassword_실패카운트가4일경우_일치하는경우_비밀번호가변경되며_실패카운트가초기회된다() { 86 | password.isMatched("netMatchedPassword"); 87 | password.isMatched("netMatchedPassword"); 88 | 89 | final String newPassword = "newPassword"; 90 | password.changePassword(newPassword, passwordValue); 91 | assertThat(password.getFailedCount(), is(0)); 92 | } 93 | 94 | @Test(expected = PasswordFailedExceededException.class) 95 | public void changePassword_비밀번호변경이_5회이상일치하지않으면_PasswordFailedExceededException() { 96 | 97 | final String newPassword = "newPassword"; 98 | final String oldPassword = "oldPassword"; 99 | 100 | password.changePassword(newPassword, oldPassword); 101 | password.changePassword(newPassword, oldPassword); 102 | password.changePassword(newPassword, oldPassword); 103 | password.changePassword(newPassword, oldPassword); 104 | password.changePassword(newPassword, oldPassword); 105 | password.changePassword(newPassword, oldPassword); 106 | 107 | } 108 | 109 | @Test 110 | public void changePassword_비밀번호변경이_4회일치하지않더라도_5회에서일치하면_실패카운트초기화() { 111 | 112 | final String newPassword = "newPassword"; 113 | final String oldPassword = "oldPassword"; 114 | 115 | password.changePassword(newPassword, oldPassword); 116 | password.changePassword(newPassword, oldPassword); 117 | password.changePassword(newPassword, oldPassword); 118 | password.changePassword(newPassword, passwordValue); 119 | 120 | 121 | assertThat(password.isMatched(newPassword), is(true)); 122 | assertThat(password.getFailedCount(), is(0)); 123 | assertThat(password.getTtl(), is(TTL)); 124 | 125 | } 126 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/common/model/DateTimeTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.common.model; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.mockito.junit.MockitoJUnitRunner; 6 | 7 | import static org.hamcrest.CoreMatchers.is; 8 | import static org.hamcrest.CoreMatchers.nullValue; 9 | import static org.junit.Assert.assertThat; 10 | 11 | @RunWith(MockitoJUnitRunner.class) 12 | public class DateTimeTest { 13 | 14 | @Test 15 | public void DateTime() { 16 | 17 | // 커버리지 위한 코드 18 | final DateTime dateTime = new DateTime(); 19 | assertThat(dateTime.getCreatedAt(), is(nullValue())); 20 | assertThat(dateTime.getUpdatedAt(), is(nullValue())); 21 | 22 | } 23 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/common/model/PageRequestTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.common.model; 2 | 3 | import org.junit.Test; 4 | import org.springframework.data.domain.Sort; 5 | 6 | import static org.hamcrest.CoreMatchers.is; 7 | import static org.junit.Assert.assertThat; 8 | 9 | public class PageRequestTest { 10 | 11 | @Test 12 | public void setSizeTest() { 13 | 14 | final PageRequest page = new PageRequest(); 15 | 16 | page.setSize(10); 17 | assertThat(page.getSize(), is(10)); 18 | 19 | // 50이 넘어가면 기본 사이즈 10 20 | page.setSize(51); 21 | assertThat(page.getSize(), is(10)); 22 | } 23 | 24 | @Test 25 | public void setPageTest() { 26 | final PageRequest page = new PageRequest(); 27 | 28 | page.setPage(10); 29 | assertThat(page.getPage(), is(10)); 30 | 31 | 32 | // 페이지 설정이 0이면 기본 페이지 1로 설정 33 | page.setPage(0); 34 | assertThat(page.getPage(), is(1)); 35 | } 36 | 37 | @Test 38 | public void setDirectionTest() { 39 | final PageRequest page = new PageRequest(); 40 | page.setDirection(Sort.Direction.ASC); 41 | assertThat(page.getDirection(), is(Sort.Direction.ASC)); 42 | } 43 | 44 | @Test 45 | public void ofTest() { 46 | final PageRequest page = new PageRequest(); 47 | page.setPage(1); 48 | page.setSize(10); 49 | page.setDirection(Sort.Direction.ASC); 50 | 51 | final org.springframework.data.domain.PageRequest pageRequest = page.of(); 52 | 53 | assertThat(pageRequest.getPageSize(), is(10)); 54 | assertThat(pageRequest.getOffset(), is(0L)); 55 | 56 | } 57 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/coupon/CouponTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.coupon; 2 | 3 | 4 | import static org.assertj.core.api.Java6Assertions.assertThat; 5 | 6 | import org.junit.Test; 7 | 8 | public class CouponTest { 9 | 10 | @Test 11 | public void builder_test() { 12 | 13 | final Coupon coupon = Coupon.builder() 14 | .discountAmount(10) 15 | .build(); 16 | 17 | assertThat(coupon.getDiscountAmount()).isEqualTo(10); 18 | assertThat(coupon.isUse()).isFalse(); 19 | 20 | 21 | } 22 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/delivery/DeliveryControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery; 2 | 3 | import static org.hamcrest.CoreMatchers.is; 4 | import static org.mockito.ArgumentMatchers.any; 5 | import static org.mockito.ArgumentMatchers.anyLong; 6 | import static org.mockito.BDDMockito.given; 7 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 8 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 9 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 10 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 11 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 12 | 13 | import com.cheese.springjpa.Account.domain.Address; 14 | import com.cheese.springjpa.error.ErrorExceptionController; 15 | import com.fasterxml.jackson.databind.ObjectMapper; 16 | import org.junit.Before; 17 | import org.junit.Test; 18 | import org.junit.runner.RunWith; 19 | import org.mockito.InjectMocks; 20 | import org.mockito.Mock; 21 | import org.mockito.junit.MockitoJUnitRunner; 22 | import org.springframework.http.MediaType; 23 | import org.springframework.test.web.servlet.MockMvc; 24 | import org.springframework.test.web.servlet.ResultActions; 25 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 26 | 27 | @RunWith(MockitoJUnitRunner.class) 28 | public class DeliveryControllerTest { 29 | 30 | @InjectMocks 31 | private DeliveryController deliveryController; 32 | 33 | @Mock 34 | private DeliveryService deliveryService; 35 | 36 | private ObjectMapper objectMapper = new ObjectMapper(); 37 | 38 | private MockMvc mockMvc; 39 | 40 | @Before 41 | public void setUp() { 42 | mockMvc = MockMvcBuilders.standaloneSetup(deliveryController) 43 | .setControllerAdvice(new ErrorExceptionController()) 44 | .build(); 45 | } 46 | 47 | @Test 48 | public void create() throws Exception { 49 | //given 50 | final Address address = buildAddress(); 51 | final DeliveryDto.CreationReq dto = buildCreationDto(address); 52 | given(deliveryService.create(any())).willReturn(dto.toEntity()); 53 | 54 | //when 55 | final ResultActions resultActions = requestCreate(dto); 56 | 57 | //then 58 | resultActions 59 | .andExpect(status().isCreated()) 60 | .andExpect(jsonPath("$.address.address1", is(dto.getAddress().getAddress1()))) 61 | .andExpect(jsonPath("$.address.address2", is(dto.getAddress().getAddress2()))) 62 | .andExpect(jsonPath("$.address.zip", is(dto.getAddress().getZip()))); 63 | 64 | } 65 | 66 | @Test 67 | public void getDelivery() throws Exception { 68 | //given 69 | final Address address = buildAddress(); 70 | final DeliveryDto.CreationReq dto = buildCreationDto(address); 71 | given(deliveryService.findById(anyLong())).willReturn(dto.toEntity()); 72 | 73 | //when 74 | final ResultActions resultActions = requestGetDelivery(); 75 | 76 | //then 77 | resultActions 78 | .andExpect(status().isOk()) 79 | .andExpect(jsonPath("$.address.address1", is(dto.getAddress().getAddress1()))) 80 | .andExpect(jsonPath("$.address.address2", is(dto.getAddress().getAddress2()))) 81 | .andExpect(jsonPath("$.address.zip", is(dto.getAddress().getZip()))); 82 | } 83 | 84 | 85 | private ResultActions requestGetDelivery() throws Exception { 86 | return mockMvc.perform(get("/deliveries/" + anyLong()) 87 | .contentType(MediaType.APPLICATION_JSON)) 88 | .andDo(print()); 89 | } 90 | 91 | private ResultActions requestCreate(DeliveryDto.CreationReq dto) throws Exception { 92 | return mockMvc.perform(post("/deliveries") 93 | .contentType(MediaType.APPLICATION_JSON) 94 | .content(objectMapper.writeValueAsString(dto))) 95 | .andDo(print()); 96 | } 97 | 98 | private Address buildAddress() { 99 | return Address.builder() 100 | .address1("address1...") 101 | .address2("address2...") 102 | .zip("zip...") 103 | .build(); 104 | } 105 | 106 | private DeliveryDto.UpdateReq buildUpdateReqDto() { 107 | return DeliveryDto.UpdateReq.builder() 108 | .status(DeliveryStatus.DELIVERING) 109 | .build(); 110 | } 111 | 112 | private DeliveryDto.CreationReq buildCreationDto(Address address) { 113 | return DeliveryDto.CreationReq.builder() 114 | .address(address) 115 | .build(); 116 | } 117 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/delivery/DeliveryLogTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery; 2 | 3 | import com.cheese.springjpa.delivery.exception.DeliveryAlreadyDeliveringException; 4 | import com.cheese.springjpa.delivery.exception.DeliveryStatusEqaulsException; 5 | import org.junit.Test; 6 | 7 | import static org.hamcrest.CoreMatchers.is; 8 | import static org.hamcrest.MatcherAssert.assertThat; 9 | 10 | public class DeliveryLogTest { 11 | 12 | @Test 13 | public void delivery_pending_로그저장() { 14 | final DeliveryStatus status = DeliveryStatus.PENDING; 15 | final DeliveryLog log = buildLog(buildDelivery(), status); 16 | 17 | assertThat(status, is(log.getStatus())); 18 | 19 | //커버리지 높이기위한 임시함수 20 | log.getDateTime(); 21 | log.getDelivery(); 22 | log.getLastStatus(); 23 | 24 | DeliveryDto.LogRes logRes = new DeliveryDto.LogRes(log); 25 | 26 | logRes.getDateTime(); 27 | logRes.getStatus(); 28 | 29 | } 30 | 31 | @Test 32 | public void delivery_delivering() { 33 | final Delivery delivery = buildDelivery(); 34 | final DeliveryStatus status = DeliveryStatus.PENDING; 35 | 36 | delivery.addLog(status); 37 | delivery.addLog(DeliveryStatus.DELIVERING); 38 | } 39 | 40 | @Test 41 | public void delivery_canceled() { 42 | final Delivery delivery = buildDelivery(); 43 | final DeliveryStatus status = DeliveryStatus.PENDING; 44 | 45 | delivery.addLog(status); 46 | delivery.addLog(DeliveryStatus.CANCELED); 47 | } 48 | 49 | 50 | @Test 51 | public void delivery_completed() { 52 | final Delivery delivery = buildDelivery(); 53 | final DeliveryStatus status = DeliveryStatus.DELIVERING; 54 | 55 | delivery.addLog(status); 56 | delivery.addLog(DeliveryStatus.COMPLETED); 57 | } 58 | 59 | @Test(expected = DeliveryStatusEqaulsException.class) 60 | public void 동일한_status_변경시_DeliveryStatusEqaulsException() { 61 | final Delivery delivery = buildDelivery(); 62 | final DeliveryStatus status = DeliveryStatus.DELIVERING; 63 | 64 | delivery.addLog(status); 65 | delivery.addLog(DeliveryStatus.DELIVERING); 66 | } 67 | 68 | @Test(expected = DeliveryAlreadyDeliveringException.class) 69 | public void 배송시작후_취소시_DeliveryAlreadyDeliveringException() { 70 | final Delivery delivery = buildDelivery(); 71 | final DeliveryStatus status = DeliveryStatus.DELIVERING; 72 | 73 | delivery.addLog(status); 74 | delivery.addLog(DeliveryStatus.CANCELED); 75 | } 76 | 77 | @Test(expected = IllegalArgumentException.class) 78 | public void 완료상태_변경시_IllegalArgumentException() { 79 | final Delivery delivery = buildDelivery(); 80 | final DeliveryStatus status = DeliveryStatus.COMPLETED; 81 | 82 | delivery.addLog(status); 83 | delivery.addLog(DeliveryStatus.CANCELED); 84 | 85 | } 86 | 87 | 88 | private DeliveryLog buildLog(Delivery delivery, DeliveryStatus status) { 89 | return DeliveryLog.builder() 90 | .delivery(delivery) 91 | .status(status) 92 | .build(); 93 | } 94 | 95 | private Delivery buildDelivery() { 96 | return Delivery.builder() 97 | .build(); 98 | } 99 | 100 | 101 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/delivery/DeliveryServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.delivery; 2 | 3 | import static org.hamcrest.CoreMatchers.is; 4 | import static org.hamcrest.Matchers.empty; 5 | import static org.junit.Assert.assertThat; 6 | import static org.mockito.ArgumentMatchers.any; 7 | import static org.mockito.ArgumentMatchers.anyLong; 8 | import static org.mockito.BDDMockito.given; 9 | import static org.mockito.Mockito.atLeastOnce; 10 | import static org.mockito.Mockito.verify; 11 | 12 | import com.cheese.springjpa.Account.domain.Address; 13 | import com.cheese.springjpa.delivery.exception.DeliveryNotFoundException; 14 | import java.util.Optional; 15 | import org.junit.Test; 16 | import org.junit.runner.RunWith; 17 | import org.mockito.InjectMocks; 18 | import org.mockito.Mock; 19 | import org.mockito.junit.MockitoJUnitRunner; 20 | 21 | @RunWith(MockitoJUnitRunner.class) 22 | public class DeliveryServiceTest { 23 | 24 | @InjectMocks 25 | private DeliveryService deliveryService; 26 | 27 | @Mock 28 | private DeliveryRepository deliveryRepository; 29 | 30 | 31 | @Test 32 | public void create() { 33 | //given 34 | final Address address = buildAddress(); 35 | final DeliveryDto.CreationReq dto = buildCreationDto(address); 36 | 37 | given(deliveryRepository.save(any(Delivery.class))).willReturn(dto.toEntity()); 38 | 39 | //when 40 | final Delivery delivery = deliveryService.create(dto); 41 | 42 | //then 43 | assertThat(delivery.getAddress(), is(address)); 44 | } 45 | 46 | @Test 47 | public void updateStatus() { 48 | //given 49 | final Address address = buildAddress(); 50 | final DeliveryDto.CreationReq creationReq = buildCreationDto(address); 51 | final DeliveryDto.UpdateReq updateReq = buildUpdateReqDto(); 52 | 53 | given(deliveryRepository.findById(anyLong())).willReturn(Optional.of(creationReq.toEntity())); 54 | //when 55 | final Delivery delivery = deliveryService.updateStatus(anyLong(), updateReq); 56 | 57 | //then 58 | 59 | assertThat(delivery.getAddress(), is(address)); 60 | assertThat(delivery.getLogs().get(0).getStatus(), is(updateReq.getStatus())); 61 | } 62 | 63 | @Test 64 | public void findById() { 65 | 66 | //given 67 | final Address address = buildAddress(); 68 | final DeliveryDto.CreationReq dto = buildCreationDto(address); 69 | given(deliveryRepository.findById(anyLong())).willReturn(Optional.of(dto.toEntity())); 70 | 71 | //when 72 | final Delivery delivery = deliveryService.findById(anyLong()); 73 | 74 | //then 75 | assertThat(delivery.getAddress(), is(address)); 76 | 77 | 78 | } 79 | 80 | @Test(expected = DeliveryNotFoundException.class) 81 | public void findById_존재하지않을경우_DeliveryNotFoundException() { 82 | //given 83 | final Address address = buildAddress(); 84 | final DeliveryDto.CreationReq dto = buildCreationDto(address); 85 | given(deliveryRepository.findById(anyLong())).willReturn(Optional.empty()); 86 | 87 | //when 88 | deliveryService.findById(anyLong()); 89 | } 90 | 91 | @Test 92 | public void removeLogs() { 93 | //given 94 | final Address address = buildAddress(); 95 | final DeliveryDto.CreationReq dto = buildCreationDto(address); 96 | given(deliveryRepository.findById(anyLong())).willReturn(Optional.of(dto.toEntity())); 97 | 98 | //when 99 | final Delivery delivery = deliveryService.removeLogs(anyLong()); 100 | 101 | //then 102 | assertThat(delivery.getLogs(), is(empty())); 103 | 104 | } 105 | 106 | @Test 107 | public void remove() { 108 | deliveryService.remove(anyLong()); 109 | 110 | verify(deliveryRepository, atLeastOnce()).deleteById(anyLong()); 111 | } 112 | 113 | private DeliveryDto.UpdateReq buildUpdateReqDto() { 114 | return DeliveryDto.UpdateReq.builder() 115 | .status(DeliveryStatus.DELIVERING) 116 | .build(); 117 | } 118 | 119 | private Address buildAddress() { 120 | return Address.builder() 121 | .address1("address1...") 122 | .address2("address2...") 123 | .zip("zip...") 124 | .build(); 125 | } 126 | 127 | private DeliveryDto.CreationReq buildCreationDto(Address address) { 128 | return DeliveryDto.CreationReq.builder() 129 | .address(address) 130 | .build(); 131 | } 132 | 133 | 134 | } -------------------------------------------------------------------------------- /src/test/java/com/cheese/springjpa/order/OrderServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.cheese.springjpa.order; 2 | 3 | import com.cheese.springjpa.coupon.Coupon; 4 | import com.cheese.springjpa.coupon.CouponService; 5 | import org.junit.Test; 6 | import org.junit.runner.RunWith; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | import org.springframework.test.context.junit4.SpringRunner; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | import static org.hamcrest.CoreMatchers.is; 13 | import static org.hamcrest.CoreMatchers.notNullValue; 14 | import static org.junit.Assert.assertThat; 15 | 16 | @RunWith(SpringRunner.class) 17 | @SpringBootTest 18 | @Transactional 19 | public class OrderServiceTest { 20 | 21 | @Autowired 22 | private OrderService orderService; 23 | 24 | @Autowired 25 | private CouponService couponService; 26 | 27 | @Test 28 | public void order_쿠폰할인적용() { 29 | final Order order = orderService.order(); 30 | 31 | assertThat(order.getPrice(), is(9_000D)); // 1,000 할인 적용 확인 32 | assertThat(order.getId(), is(notNullValue())); // 1,000 할인 적용 확인 33 | assertThat(order.getCoupon(), is(notNullValue())); // 1,000 할인 적용 확인 34 | 35 | final Order findOrder = orderService.findById(order.getId()); 36 | System.out.println("couponId : " + findOrder.getCoupon().getId()); // couponId : 1 (coupon_id 외래 키를 저장 완료) 37 | 38 | final Coupon coupon = couponService.findById(1); 39 | assertThat(coupon.isUse(), is(true)); 40 | assertThat(coupon.getId(), is(notNullValue())); 41 | assertThat(coupon.getDiscountAmount(), is(notNullValue())); 42 | } 43 | 44 | @Test 45 | public void use_메서드에_order_set_필요이유() { 46 | final Order order = orderService.order(); 47 | assertThat(order.getPrice(), is(9_000D)); // 1,000 할인 적용 확인 48 | final Coupon coupon = order.getCoupon(); 49 | assertThat(coupon.getOrder(), is(notNullValue())); 50 | } 51 | } --------------------------------------------------------------------------------