├── .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 | [](https://travis-ci.org/cheese10yun/spring-jpa-best-practices)
2 | [](https://coveralls.io/github/cheese10yun/spring-jpa-best-practices?branch=master)
3 | [](https://codecov.io/gh/cheese10yun/spring-jpa-best-practices)
4 | [](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 | 
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 | 
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 | 
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 | 
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 | 
189 | * nullable = false 없는 경우, outer join
190 |
191 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | }
--------------------------------------------------------------------------------