☝️ 스프링과 JPA 기반 웹 애플리케이션 개발 - 백기선님.
- 라이브러리
- Lombok
- Spring Boot Devtools
- Spring Configuration processor : Custom properties 자동완성 지원
- Thymeleaf
- Spring Security
- Srping Data JPA
- PostgreSQL, H2
- Java mail sender (Spring mail)
- QueryDSL
- Spring Validation
- 타임리프 : 객체를 폼 객체로 설정하기
th:object="${signUpForm}"
th:field="*{nickName}"
th:field="*{email}"
th:field="*{password}"model.addAttribute(new SignUpForm());<form class="needs-validation col-sm-6"
required minlength="3" maxlength="20"
input type email, password
<small class="invalid-feedback">닉네임을 입력하세요.</small>
<small class="form-text text-danger" th:if="${#fields.hasErrors('nickName')}" th:errors="*{nickName}">Nickname Error</small><script type="application/javascript" th:fragment="form-validation">
(function () {
'use strict';
window.addEventListener('load', function () {
var forms = document.getElementsByClassName('needs-validation');
Array.prototype.filter.call(forms, function (form) {
form.addEventListener('submit', function (event) {
if (form.checkValidity() === false) {
.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated')
}, false)
})
}, false)
}())
</script>form.checkValidity 에서 html에서 선언한 검증 체크. 유효하지 않을 경우 class ="invalid-feedback"에 해당하는 값 출력. th:errors 에는 서버에서 검증한 해당 필드에 대한 에러가 담김.
- 값 길이, 필수 값
@NotBlank
@Length(min=3, max=20)
@Pattern(regexp = "([ㄱ-ㅎ가-힣-a-z0-9_-]{3,20}$)")
- 중복 이메일, 닉네임 여부 확인.
implements Validator
@PostMapping("/sign-up") public String signUpSubmit(@Valid SignUpForm signUpForm, Errors errors){ if(errors.hasErrors()){ return "account/sign-up"; } return "redirect:/"; }
- Errors 는 반드시 @Valid 객체 다음으로 와야함.
- 에러가 있을 때 form 객체를 자동으로 model에 추가하여 돌려줌. (입력값 포함)
- 커스텀 검증
@Override
public boolean supports(Class<?> aClass) {
return aClass.isAssignableFrom(SignUpForm.class);
}
@Override
public void validate(Object o, Errors errors) {
//TODO email, nickname 중복 여부.
SignUpForm signUpForm = (SignUpForm) errors;
if(accountRepository.existsByEmail(signUpForm.getEmail())){
errors.rejectValue("email","invalid.email", new Object[]{signUpForm.getEmail()}, "이미 사용중인 이메일 입니다.");
}
if(accountRepository.existsNickName(signUpForm.getNickName())){
errors.rejectValue("nickName","invalid.nickName", new Object[]{signUpForm.getNickName()}, "이미 사용중인 닉네 입니다.");
}
}- @InitBinder를 이용한 Validator 등록.
//객체의 카멜케이스.
@InitBinder("signUpForm")
public void initBinder(WebDataBinder webDataBinder){
webDataBinder.addValidators(signUpFormValidator);
}- 메서드에 errors와 검증 객체를 넘겨 error 받아오기
sighUpFormValidator.validate(signUpForm, errors);
if(errors.hasError()){
...
}- validate 뿐만 아니라 새로운 메서드를 생성하여 같은 방식으로 검증 가능.
private Account saveNewAccount(SignUpForm signUpForm) {
Account account = Account.builder()
.email((signUpForm.getEmail()))
.nickName(signUpForm.getNickName())
.password(passwordEncoder.encode(signUpForm.getPassword()))
.studyCreatedByWeb(true)
.studyEnrollmentResultByWeb(true)
.studyUpdatedByWeb(true)
.build();
Account newAccount = accountRepository.save(account);
return newAccount;
}newAccount.generateEmailCheckToken();
private void sendSignUpConfirmEmail(Account newAccount) {
SimpleMailMessage mailMessage = new SimpleMailMessage();
mailMessage.setTo(newAccount.getEmail());
mailMessage.setSubject("회원 가입 인증");
//check-email-token 에서 token이 유효한지 확인.
mailMessage.setText("/check-email-token?token="+ newAccount.getEmailCheckToken()
+ "&email=" + newAccount.getEmail());
javaMailSender.send(mailMessage);
}- CSRF Token : 타 사이트에서 폼 데이터를 전송하는 것을 방지.
- 입력값이 정상 일 경우, 잘못 된 입력 값일 경우 테스트.
@MockBean
JavaMailSender javaMailSender;
@DisplayName("회원 가입 처리 - 입력값 정상")
@Test
void signUpSubmit_with_correct_input() throws Exception {
mockMvc.perform(post("/sign-up")
.param("nickName","bigave")
.param("email", "correct@email.com")
.param("password","12345678")
.with(csrf())) //csrf token을 넣어줌.
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/"));
Account account =accountRepository.findByEmail("correct@email.com");
assertNotNull(account);
assertNotNull(account.getEmailCheckToken());
//send 메서드가 호출 되었는지. org.mockito
then(javaMailSender).should().send(any(SimpleMailMessage.class));
}@GetMapping("/check-email-token")
public String checkEmailToken(String token, String email, Model model){
Account account = accountRepository.findByEmail(email);
String view = "account/checked-email";
if(account == null){
model.addAttribute("error", "wrong.email");
return view;
}
if (!account.getEmailCheckToken().equals(token)){
model.addAttribute("error", "wrong.token");
return view;
}
account.setEmailVerified(true);
account.setJoinedAt(LocalDateTime.now());
model.addAttribute("numberOfUser", accountRepository.count());
return view;
}
- 인증 메일 정보가 올바르지 않다면 error를 담고, 올바르면 이메일 인증표시를 하고 가입 날짜를 계정정보에 추가.
- checked-email 페이지에서 error의 여부에 따라 메시지를 보여준다.
- 스프링 시큐리티에서 로그인 : SecurityContext에 Authentication(token)이 존재 하는가.
- UsernamePasswordAuthenticationToken 으로 token을 생성하고 SecuriryContext에 넣어준다.
public void login(Account account) {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
account.getNickName(),
account.getPassword(),
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
SecurityContext context = SecurityContextHolder.getContext();
context.setAuthentication(token);
}<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
<html xmlns:sec="http://www.thymeleaf.org/extras/spring-security"></html>
<li class="nav-item" sec:authorize="!isAuthenticated()">
<a class="nav-link" href="#" th:href="@{/login}">로그인</a>
</li>- isAuthenticated()를 이용하여 인증 상태인지 여부를 가져올 수 있다.
${#authentication.name} 로 이름 참조도 가능.
- Web Jar , NPM
- WebJar는 라이브러리 업데이트가 느리고, 올라오지 않은 라이브러리도 흔하다.
- src/main/resource/static 이하에서는 정적 리소스로 제공(스프링 부트)
- package.json에 프론트엔드 라이브러리를 제공
- static 디렉토리 아래에 package.json을 위치. -> 정적 리소스로 프론트엔드 라이브러리 사용.
static 디렉토리로 이동.
npm init
npm install bootstrap
npm install jquery
- .ginignore에 node_modules, node 추가.
<link rel="stylesheet" href="/node_modules/bootstrap/dist/css/bootstrap.css">
<script src="/node_modules/jquery/dist/jquery.js"></script>
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.js"></script>
- pom.xml을 빌드할 때 static 아래 package.json도 빌드하도록 설정.
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.8.0</version>
<configuration>
<nodeVersion>v4.6.0</nodeVersion>
<workingDirectory>src/main/resources/static</workingDirectory>
</configuration>
<executions>
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<phase>generate-resources</phase>
</execution>
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<arguments>install</arguments>
</configuration>
</execution>
</executions>
</plugin> @Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.mvcMatchers("/node_modules/**")
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}- CommonLocation에서는 node_modules를 포함하지 않기 때문에 따로 추가해 주어야 한다.
- npm install jdenticon
- npm install font-awesome
<link rel="stylesheet" href="/node_modules/font-awesome/css/font-awesome.css">
<script src="/node_modules/jdenticon/dist/jdenticon.js"></script>
<i class="fa fa-bell-o"> </i>
<svg data-jdenticon-value="user127" th:data-jdenticon-value="${#authentication.name}" width="24" height="24" class="rounded border bg-light"></svg>- font-awesome : fa {docs 참조해서 아이콘 id}
- jdenticon : name에 따라 다른 값이 들어가도록 설정 함.
<div class ="alert alert-warning" role="alert" th:if="${account != null && !account.emailVerified}" >
가입을 완료하려면 <a href="#" th:href="@{/check-email}" class="alert-link">계정 인증 이메일을 확인 하세요.</a>
</div>@GetMapping("/")
public String home(@CurrentUser Account account, Model model){
if(account != null){
model.addAttribute(account);
}
return "index";@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AuthenticationPrincipal(expression = "#this =='anonymousUser' ? null :account")
public @interface CurrentUser {
}- User인증이 되지 않으면 Principal 은 "anonymousUser"라는 문자열. 인증이 되어있지 않다면 null을 인증이 되어있다면 principal에서 account 객체를 꺼내 넘겨준다.
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
new UserAccount(account),
account.getPassword(),
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);- login에서 AuthenticationPrincipal을 닉네임이 아닌 UserAccount로 변경.
@Getter
public class UserAccount extends User {
private Account account;
public UserAccount(Account account) {
super(account.getNickName(), account.getPassword(), List.of(new SimpleGrantedAuthority("ROLE_USER")));
this.account =account;
}
}- account 라는 필드명은 @CurrentUser Account account 의 account 와 매핑된다.
@GetMapping("/resend-confirm-email")
public String resendPage(@CurrentUser Account account, Model model){
if(!account.canConfirmEmail()){
model.addAttribute("error", "인증 이메일은 10분에 한번만 전송할 수 있습니다.");
model.addAttribute("email",account.getEmail());
return "account/check-email";
}
accountService.resendConfirmEmail(account.getNickName());
return "redirect:/";
}@Transactional
public void resendConfirmEmail(String nickName) {
Account account = accountRepository.findByNickName(nickName);
account.generateEmailCheckToken();
SimpleMailMessage simpleMailMessage = new SimpleMailMessage();
simpleMailMessage.setTo(account.getEmail());
simpleMailMessage.setSubject("스터디웹 회원 인증");
simpleMailMessage.setText("/check-email-token?token="+account.getEmailCheckToken()
+"&email="+account.getEmail());
javaMailSender.send(simpleMailMessage);
}- canConfirmEmail()에서는 현재 시간과 마지막으로 checkToken을 생성한 시간을 비교하여 10이 지나지 않았다면 전송하지 않고 경고창.
- 토큰을 새로 만들어 이메일 전송.
http.formLogin()
.loginPage("/login").permitAll()
.and()
.logout().logoutSuccessUrl("/");@Override
public UserDetails loadUserByUsername(String emailOrNickName) throws UsernameNotFoundException {
Account account = accountRepository.findByEmail(emailOrNickName);
if(account == null){
account = accountRepository.findByNickName(emailOrNickName);
}
if(account == null){
throw new UsernameNotFoundException(emailOrNickName);
}
return new UserAccount(account);
}- UserDetailsService의 loadUserByUsername을 구현.
- User를 상속한 UserAccoun를 반환.
private Account account;
public UserAccount(Account account) extends User{
super(account.getNickName(), account.getPassword(), List.of(new SimpleGrantedAuthority("ROLE_USER")));
this.account =account;
}- 기본으로 사용되는 formLogin()이 아닌 새로 login.html을 만들어 매핑.
- Spring Security의 User을 상속받는 클래스인 UserAccount를 이용하여 인증.
- return User() 로도 가능.
- 기본적으로 Session의 타임 아웃은 30분.
server.servlet.session.timeout=30m-
세션이 만료 되더라도 로그인을 유지하기 위해 사용하는 방법(RememberMe)
쿠키에 인증 정보를 남겨두고 세션이 만료 됐을 때 쿠키에 남아있는 정보로 인증.
-
해시 기반의 쿠키
- UserName
- Password
- 만료기간
- Key
- 쿠키를 탈취당하면 그 계정을 탈취당한 것과 같다.
- 조금 더 안전하게 관리하기
- 쿠키 안에 랜덤한 token을 만들어 같이 저장하고 인증 때마다 변경.
- Username, 토큰
- 해당 방법도 취약, 해커가 쿠키로 인증을 하게되면 원 사용자는 인증할 수 없게 됨.
- 개선된 방법
- UserName, Token(랜덤, 매번 변경), 시리즈(랜덤,고정)
- 쿠키를 탈취 당하면 원 사용자는 유효하지 않은 토큰과 유효한 시리즈,UserName 으로 접속하게 되고, 이 경우, 모든 토큰을 삭제하여 해커가 더이상 쿠키를 사용하지 못하도록 방지할 수 있다.
http.rememberMe().key("랜덤 키값");<div class="form-grop form-check">
<input type="checkbox" class="form-check-input" id="rememberMe", name="remember-me" checked>
<label class="form-check-label" for="rememberMe" aria-describedby="rememberMeHelp">로그인 유지</label>
</div>- name 을 remember-me로 주고 check box가 true 값이면 remember-me 기능 실행.
private final AccountService accountService;
private final DataSource dataSource;
http.rememberMe()
.userDetailsService(accountService)
.tokenRepository(tokenRepository());
@Bean
public PersistentTokenRepository tokenRepository(){
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}- userDetailsService와 TokenRepository를 넘겨줌. 여기서는 UserDetilasService를 구현한 AccountService를 넘겨 주었다.
@Getter @Setter
@Table(name = "persistent_logins")
@Entity
public class PersistentLogins {
@Id
@Column(length = 64)
private String series;
@Column(nullable = false, length = 64)
private String username;
@Column(nullable = false, length = 64)
private String token;
@Column(name = "last_used",nullable = false, length = 64)
private LocalDateTime lastUsed;
}- JdbcTokenRepositoryImpl에서 만드는 테이블 엔티티를 매핑 시켜준다.
- JdbcTokenRepositoryImpl class 에서 확인할 수 있다.
@GetMapping("/profile/{nickname}")
public String viewProfile(@PathVariable String nickname, @CurrentUser Account account, Model model){
Account byNickName = accountRepository.findByNickName(nickname);
if(byNickName == null){
throw new IllegalArgumentException(nickname+"에 해당하는 사용자가 없습니다");
}
//account
model.addAttribute(byNickName);
model.addAttribute("isOwner", byNickName.equals(account));
return "account/profile";
}<div class="row mt-5 justify-content-center">
<div class="col-2">
<!-- image -->
<svg th:if="${#strings.isEmpty(account.profileImage)}" class="img-fluid float-left rounded img-thumbnail"
th:data-jdenticon-value="${account.nickName}" width="125", height="125"></svg>
<img th:if="${!#strings.isEmpty(account.profileImage)}" class="img-fluid float-left rounded img-thumbnail"
th:src="${account.profileImage}" width="125", height="125"/>
</div>
<div class="col-8">
<h1 class="display-4" th:text="${account.nickName}"></h1>
<p class="lead" th:if="${!#strings.isEmpty(account.bio)}" th:text="${account.bio}"></p>
<p class="lead" th:if="${#strings.isEmpty(account.bio) && isOwner}" >
<span>한 줄 소개를 추가하세요.</span>
</p>
</div>
</div> @PostMapping("/settings/profile")
public String RedirectMessage(RedirectAttributes attributes){
attributes.addFlashAttribute("message", "RedirectMessage");
return "redirect:/settings/profile";
}<div th:if="${message}" class ="alert alert-success alert-dismissible fade show mt-3" role="alert">
<svg class="bi flex-shrink-0 me-2" width="24" height="24" role="img" aria-label="Success:"><use xlink:href="#check-circle-fill"/></svg>
<span th:text="${message}">메시지</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</button>
</div>- redirect할 때는 Model에 데이터를 담아도 넘어가지 않는다. 이때 RedirectAttribute 를 이용하여 리다이렉트 할 때 데이터를 전달할 수 있다.
- FlashAttribute 는 한번 사용하고 사라지는 일회성 데이터.
- model에 추가된 어트리뷰트처럼 사용하면 된다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithAccountSecurityContextFactory.class )
public @interface WithAccount {
String value();
}@RequiredArgsConstructor
public class WithAccountSecurityContextFactory implements WithSecurityContextFactory<WithAccount> {
private final AccountService accountService;
@Override
public SecurityContext createSecurityContext(WithAccount withAccount) {
SignUpForm signUpForm = new SignUpForm();
signUpForm.setNickName(withAccount.value());
signUpForm.setEmail("test@email.com");
signUpForm.setPassword("123123123");
accountService.processNewAccount(signUpForm);
UserDetails principal = accountService.loadUserByUsername(withAccount.value());
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
return context;
}
}- 테스트를 진행할 때 특정 컨택스트가 등록되어 있어야하는 테스트의 경우(로그인) Securitycontext를 생성하여 등록한 후 테스트를 진행할 수 있다.
- 계정 생성
- UserDetails 객체 생성. (springSecurity.core.User)
- 인증 토큰 생성(principal, password, Authorities)
- Security Context를 생성하고 인증토큰을 등록.
<div class="form-group">
<input id="profileImage" type="hidden" th:field="*{profileImage}" class="form-control" />
</div> <img th:if="${!#strings.isEmpty(profile.profileImage)}" class="rounded"
th:src="${profile.profileImage}" width="125" height="125"/>- String 타입으로 이미지를 받을 수 있다. HTML의 DataURL 이미지는 data:image로 시작
if (!e.target.result.startsWith("data:image"))처럼 이미지인지 확인 가능.- server.tomcat.max-http-form-post-size=5MB 와같이 설정하여 form으로 전송가능한 이미지 크기 변경가능.
<div class="custom-control custom-switch custom-control-inline">
<input type="checkbox" th:field="*{studyCreatedByEmail}" class="custom-control-input" id="studyCreatedByEmail">
<label class="custom-control-label" for="studyCreatedByEmail">이메일로 받기</label>
</div> - 똑같이 field로 받고 boolean 값으로 저장. 체크하면 true
- 객체의 프로퍼를 다른 객체의 프로퍼티로 매핑해주는 유틸리티 라이브러
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>2.4.4</version>
</dependency>@Bean
public ModelMapper modelMapper(){
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration()
.setDestinationNameTokenizer(NameTokenizers.UNDERSCORE)
.setSourceNameTokenizer(NameTokenizers.UNDERSCORE);
return modelMapper
}- NameTokenizers.UNDERSCORE : _를 사용했을 때에만 nested 객체(account_nickName -> account.nickName)를 참조하는 것으로 간주, 아니라면 해당 객체의 프로퍼티로 간주한다.
//modelMapper.map(source, destination)
1. modelMapper.map(profile, account);
2. model.addAttribute(modelMapper.map(account, Notification.class));
- source 객체의 프로퍼티 값들을 destination 객체의 프로퍼티 값에 매핑시켜 값을 할당한다.
- 2번과 같이 새로운 객체를 생성하여 할당도 가능.
<script type="application/javascript" th:inline="javascript">
var csrfToken = /*[[${_csrf.token}]]*/null;
var csrfHeader = /*[[${_csrf.headerName}]]*/null;
$(document).ajaxSend(function (e, xhr, options){
xhr.setRequestHeader(csrfHeader, csrfToken);
});
</script>- 헤더에 csrf 토큰 추가.
@ManyToMany
private Set<Tag> tags;
public void addTag(Account account, Tag tag) {
Optional<Account> byId = accountRepository.findById(account.getId());
byId.ifPresent(a -> a.getTags().add(tag));
}import com.fasterxml.jackson.databind.ObjectMapper;
private ObjectMapper objectMapper;
List<String> allTags = tagRepository.findAll().stream().map(Tag::getTitle).collect(Collectors.toList());
model.addAttribute("whitelist", objectMapper.writeValueAsString(allTags));- String List를 String Json으로 변환
- readTree : String -> JsonNode
- readValue : Json -> Object, Json -> Map
- 등 많은 메소드 지원.
create database {name};
create user {name} with encrypted password 'password';
//권한 부
grant all privileges on database {dbname} to {username};
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL = DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder = TRACE- 구글 Gmail을 SMTP 서버로 사용하기.(일일 제한)
https://support.google.com/mail/answer/185833성
App 패스워드 생성.
# mail 설정
spring.mail.host = smtp.gmail.com
spring.mail.port=587
spring.mail.username =kimtaejun9705@gmail.com
spring.mail.password ={생성된 패스워드}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.starttls.enable=true- spring boot 에서 해당 설정들을 가지고 JavaMailSender Bean 을 자동으로 등록 해준다.
- https://mailchimp.com/
- https://sendgrid.com/
- https://www.mailgun.com/
- https://aws.amazon.com/ses/
- https://gsuite.google.com
public class HtmlEmailService implements EmailService{
private final JavaMailSender javaMailSender;
@Override
public void send(EmailMessage emailMessage) {
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
try{
MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage,true,"UTF-8");
mimeMessageHelper.setTo(emailMessage.getTo());
mimeMessageHelper.setSubject(emailMessage.getSubject());
mimeMessageHelper.setText(emailMessage.getText(),true);
javaMailSender.send(mimeMessage);
log.info("sent email:{}",emailMessage.getText());
}catch (MessagingException e){
log.error("failed to send email : ",e);
}
}
}- MimeMessage를 생성하고, 이를 Helper로 감싼 후 To, Subject, Text 등 기본 정보값 세팅.
- Helper 생성자의 두번째 인자는 Multipart의 여부이고, text의 두번째 인자값은 html인지.
@Builder
@Data
public class EmailMessage {
private String to;
private String subject;
private String text;
}- Email data를 넣을 클래스를 생성하였고, 이를 send의 파라미터로 전달해준다.
// Context 세팅
Context context = new Context();
context.setVariable("nickName",account.getNickName() );
context.setVariable("link" ,"/check-email-token?token="+ account.getEmailCheckToken()
+ "&email=" + account.getEmail());
context.setVariable("linkName","이메일 인증하기.");
context.setVariable("message","이메일 인증을 완료하려면 아래 링크를 클릭하세요.");
context.setVariable("host",appProperties.getHost());
// context 값이 들어간 html 파일 생성.
String message = templateEngine.process("mail/simple-link", context);
// EmailMessage 생성
EmailMessage emailMessage = EmailMessage.builder()
.to(account.getEmail())
.subject("스터디 웹 회원 인증")
.text(message)
.build();
// EmailServie의 구현체의 send 실행.
emailService.send(emailMessage);- 템플릿 엔진을 사용하여 html 파일을 만들고, TemplateEngine.process("html", context)
- context는 View에 전달하는 model과 같은 역할, 데이터를 전달해준다. thymeleaf의 Context
app.host = http://localhost:8080@Component
@Data
@ConfigurationProperties("app")
public class AppProperties {
String host;
}- host는 실행 환경에 따라 달라질 수 있기 때문에 따로 properties값으로 설정하여 넘겨준다.
- 빈으로 주입받아 사용.
- https://summernote.org/
- 부트스트랩과 연동이 편함.
npm install summernotecss, js 추가
<script type="application/javascript">
$(function () {
$("#fullDescription").summernote({
fontName: ['Arial', 'Arial Black', 'Comic Sans MS', 'Courier New', 'Noto Sans KR', 'Merriweather'],
placeholder: '스터디의 목표, 일정, 진행 방식, 모집 중인 스터디원 등 상세한 설명을 적어주세요.',
tabsize:2,
height: 300
});
})
</script>- .summernote 하면 적용, 옵션 추가 가능.
<div th:fragment="settingsFragment (currentMenu)">
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link" th:href="@{/settings/profile}" th:classappend="${currentMenu =='profile'}? active" role="tab" >프로필</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link text-red" th:href="@{/settings/password}" th:classappend="${currentMenu =='password'}? active" role="tab">패스워드</a>
</li>
</ul>
</div><div th:replace="fragment :: settingsFragment (currentMenu='profile')"/><span th:if="${!study.published}"
class="d-inline-block" tabindex="0" data-toggle="tooltip" data-placement="bottom"
title="스터디 공개 준비중">
<button class="btn btn-primary btn-sm" style="pointer-events: none;" type="button" disabled>DRAFT</button>
</span><script type="application/javascript">
$(function () {
$('[data-toggle="tooltip"]').tooltip()
})
</script>- 나중에 필요한 쿼리를 한번에 던져 쿼리의 수를 줄인다.
- 작은 쿼리 여러개 -> 무거운 커리 한개. 많은 수의 요청에서 유리할 수 있다.
@NamedEntityGraph(name = "Study.withAll", attributeNodes = {
@NamedAttributeNode("tags"),
@NamedAttributeNode("zones"),
@NamedAttributeNode("managers"),
@NamedAttributeNode("members")
})Entity 클래스에 그래프 이름 정의.
@EntityGraph(value="Study.withAll", type = EntityGraph.EntityGraphType.LOAD)
Study findByPath(String path);
- findByPath() 메서드가 호출 될 때 Study.withAll에 해당하는 릴레이션을 모두 조회한다.
- EntityGraphType.LOAD : 명시된 연관관계는 EAGER로, 나머지는 기본 설정( *ToOne->EAGER, *ToMany-> Lazy)에 따름.
@EntityGraph(attributePaths = {"managers", "members"})
Study findStudyWithTeamsByPath(String path);@NamedEntityGraph(
name = "Enrollment.withEventAndStudy",
attributeNodes = {
@NamedAttributeNode(value = "event", subgraph = "study")
},
subgraphs = @NamedSubgraph(name = "study", attributeNodes = @NamedAttributeNode("study"))
)- 엔티티가 참조하는 엔티티의 필드를 가져올 수 있다.
- Enrollment.event.study
@EntityGraph(attributePaths = {"event", "event.study"})
Study findStudyWithTeamsByPath(String path);- 한번만 사용할 것이라면, 마찬가지로 이름을 지정하지 않고 적용할 수도 있다.
public Study getStudyToUpdate(Account account, String path) {
Study study = getStudy(path);
if(!study.getManagers().contains(account)){
throw new AccessDeniedException("해당 기능에 대한 권한이 없습니다.");
}
return study;
}
private Study getStudy(String path) {
Study study = studyRepository.findByPath(path);
if(study == null){
throw new IllegalArgumentException(path +"에 해당하는 스터디가 존재하지 않습니다.");
}
return study;
}- url로 권한이 없는 사용자가 접근하거나, 잘못된 Path로 접근하는 것을 처리.
- @ExceptionHandler에서 예외 처리가 가능하다.
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="leaveTitle" th:text="${event.title}"></h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<p>모임 참가 신청을 취소하시겠습니까?</p>
<p><strong>확인</strong>하시면 본 참가 신청을 취소하고 다른 대기자에게 참석 기회를 줍니다.</p>
<p>감사합니다.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">닫기</button>
<form th:action="@{'/study/' + ${study.path} + '/events/' + ${event.id} + '/disenroll'}" method="post">
<button class="btn btn-primary" type="submit" aria-describedby="submitHelp">확인</button>
</form>
</div>
</div>npm install moment --save
<div th:fragment="date-time">
<script src="/node_modules/moment/min/moment-with-locales.js"></script>
<script type="application/javascript">
$(function () {
moment.locale('ko');
$(".date-time").text(function(index, dateTime) {
return moment(dateTime, "YYYY-MM-DD`T`hh:mm").format('LLL'); // 워ㅓㄹ, 일, 년, 시간 ,오전|오후
});
$(".date").text(function(index, dateTime) {
return moment(dateTime, "YYYY-MM-DD`T`hh:mm").format('LL'); // 월, 일, 년
});
$(".weekday").text(function(index, dateTime) {
return moment(dateTime, "YYYY-MM-DD`T`hh:mm").format('dddd'); // 요일
});
$(".time").text(function(index, dateTime) {
return moment(dateTime, "YYYY-MM-DD`T`hh:mm").format('LT'); // 시간 오전|오후
});
$(".calendar").text(function(index, dateTime) {
return moment(dateTime, "YYYY-MM-DD`T`hh:mm").calendar(); // 요 시간 오전|오후
});
$(".fromNow").text(function(index, dateTime) {
return moment(dateTime, "YYYY-MM-DD`T`hh:mm").fromNow(); // 지금으로 부터 상대시간.
});
$(".date-weekday-time").text(function(index, dateTime) {
return moment(dateTime, "YYYY-MM-DD`T`hh:mm").format('LLLL'); // 요일, 월, 일, 년 시간 오전|오후
});
})
</script>
</div>- 서버의 형식인 YYYY-MM-DD
Thh:mm 을 포매팅해준다. - 상대 시간 ( 25분전 )도 가능.
- https://momentjs.com/ 참조.
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))로 서버에 저장되는 날짜 형식도 변경할 수 있다.
<span th:if="${event.eventType == T(com.studyweb.studyweb.event.EventType).FCFS}">선착순</span>
<span th:if="${event.eventType == T(com.studyweb.studyweb.event.EventType).CONFIRMATIVE}">관리자 확인</span>-T(FQCN)
- 쿼리 1번으로 N건을 가져왔는데 관련 컬럼을 얻기위해 N번의 추가 쿼리를 수행하는 문제.
- 성능상에 영향을 줄 수 있다.
ex) event 조회시, enrollments를 event당 한 번씩 조회하므로 N개의 이벤트를 조회하면 N번의 enrollments 쿼리가 발생한다.
// Event는 enrollments를 가짐.
@OneToMany(mappedBy = "event")
private List<Enrollment> enrollments;-> Event하나를 조회할 때마다 enrollments를 조회하는 쿼리가 발생.
@NamedEntityGraph(name = "Event.withEnrollments", attributeNodes = {
@NamedAttributeNode("enrollments")
})@EntityGraph(value = "Event.withEnrollments", type= EntityGraph.EntityGraphType.LOAD)
List<Event> findByStudyOrderByStartDateTime(Study study);- EntityGraph를 이용하여 Event들이 조회될 때 enrollments를 같이 가져오도록 한다.
-
fetchJoin 으로도 해결할 수 있다.
spring.mvc.hiddenmethod.filter.enabled=true-HTML Form 에서 Put, Delete 메소드를 사용한요청을 매핑 가능하게 해준다.
- 기존 코드
@PostMapping("/events/{eventId}/enrollments/{enrollId}/accept")
public String acceptUser(@PathVariable Long eventId, @PathVariable Long enrollId){
Event event = eventService.getEventById(eventId);
Enrollment enrollment = enrollmentRepository.findById(enrollId).orElseThrow();
eventService.acceptUser(event, enrollment);
return "redirect:/study/"+study.getPath(path) + "/events/"+eventId;
}- 개선 후
@PostMapping("/events/{eventId}/enrollments/{enrollId}/accept")
public String acceptUser(@PathVariable("eventId") Event event, @PathVariable("enrollId") Enrollment enrollment){
eventService.acceptUser(event, enrollment);
return "redirect:/study/"+study.getPath(path) + "/events/"+event.getId();
}<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>0.13.1</version>
<scope>test</scope>
</dependency>package com.studyweb.studyweb;
import com.studyweb.studyweb.modules.account.Account;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
@AnalyzeClasses(packagesOf = StudywebApplication.class)
public class PackageDependencyTests {
private static final String STUDY = "..modules.study..";
private static final String EVENT = "..modules.event..";
private static final String ACCOUNT = "..modules.account..";
private static final String TAG = "..modules.zone..";
private static final String ZONE = "..modules.tag..";
// Modules 패키지는 Modules 패키지에서만 참조.
@ArchTest
ArchRule modulesRule = classes().that().resideInAnyPackage("com.studyweb.studyweb.modules..")
.should().onlyBeAccessed().byClassesThat()
.resideInAnyPackage("com.studyweb.studyweb.modules..");
// Study 패키지는 Study, Event 패키지에서만 참조.
@ArchTest
ArchRule studyPackageRule = classes().that().resideInAPackage(STUDY)
.should().onlyBeAccessed().byClassesThat()
.resideInAnyPackage(STUDY,EVENT);
// Account 패키지는 Account,Tag,Zone 패키지를 참조
@ArchTest
ArchRule accountPackageRule = classes().that().resideInAPackage(ACCOUNT)
.should().accessClassesThat()
.resideInAnyPackage(ACCOUNT, TAG, ZONE);
//Event 패키지는 Account,Study, Event 패키지를 참조.
@ArchTest
ArchRule eventPackageRule = classes().that().resideInAPackage(EVENT)
.should().accessClassesThat()
.resideInAnyPackage(ACCOUNT, STUDY, EVENT);
// 순환참조가 없어야 함.
@ArchTest
ArchRule cycleCheck = slices().matching("com.studyweb.studyweb.modules.(*)..")
.should().beFreeOfCycles();
}- 테스트를 실행하면 위반되는 곳을 로그로 출력해 준다. 잘못된 참조를 하고 있는 부분을 수정.
- 테스트용 DB를 따로 운영하는 것은 번거롭다.
TestContainers를 사용하여 구성.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.13.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.13.0</version>
<scope>test</scope>
</dependency>private final ApplicationEventPublisher applicationEventPublisher;
applicationEventPublisher.publishEvent(new StudyCreatedEvent(study));- 이벤트를 applicationEventPublisher에 등록.
@Getter
public class StudyCreatedEvent {
private Study study;
public StudyCreatedEvent(Study study) {
this.study = study;
}
}@Async
@Slf4j
@Transactional(readOnly = true)
@Component
public class StudyEventHandler {
@EventListener
public void handleStudyCreatedEvent(StudyCreatedEvent studyCreatedEvent){
log.info(studyCreatedEvent.getStudy().getTitle() + " is created.");
//TODO 이메일, 알림 처리
}
}- EventListener에서 처리 Event 객체를 받아 처리.
- 비동기로 처리하기 위해 @Async 사용.
- 예외가 발생해도 main은 그대로 실행 됨.
- 성능에 영향을 주지 않도록 하기 위해.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int corePoolSize = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(corePoolSize * 2);
executor.setQueueCapacity(50);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("AsyncExcutor-");
executor.initialize();
return executor;
}
}- AsyncConfigurer의 Executor를 구현하여 풀 사이즈, 큐 사이즈 세팅
- setCorePoolSize() -- 사용할 코어 사이즈 설정.
- setQueueCapacity() : 코어 사이즈 만큼 쓰레드가 생성 되었을 때 대기열의 크기. 기본값은 int max
- setMaxPoolSize() : 대기열이 모두 가득 찼을 때 추가로 생성될 수 있는 쓰레드의 맥스 코어 사이즈.
- setKeepAliveSeconds() : 코어 사이즈를 넘어 생성된 쓰레드를 회수하는 시간.
- setThreadNamePrefix() : 쓰레드 이름.
- 타입 세이프 하게 JPA 쿼리를 작성할 수 있게 해준다.
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
</dependency><plugin>
<groupId>com.mysema.maven</groupId>
<artifactId>apt-maven-plugin</artifactId>
<version>1.1.3</version>
<executions>
<execution>
<goals>
<goal>process</goal>
</goals>
<configuration>
<outputDirectory>target/generated-sources/java</outputDirectory>
<processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<version>4.2.2</version>
</dependency>
</dependencies>
</plugin>
- QEntity를 만들어줘야 하지만 플러그인을 사용하면 자동으로 만들어준다.
- 플러그인 추가 후 Compile
extends QuerydslPredicateExecutor<Account>- QuerydslPredicateExecutor를 상속 받아 해당 메소드들을 사용 한다.
public interface QuerydslPredicateExecutor<T> {
Optional<T> findOne(Predicate var1);
Iterable<T> findAll(Predicate var1);
Iterable<T> findAll(Predicate var1, Sort var2);
Iterable<T> findAll(Predicate var1, OrderSpecifier<?>... var2);
Iterable<T> findAll(OrderSpecifier<?>... var1);
Page<T> findAll(Predicate var1, Pageable var2);
long count(Predicate var1);
boolean exists(Predicate var1);
}public class AccountPredicate {
public static Predicate findByTagsAndZones(Set<Tag> tags, Set<Zone> zones){
QAccount account = QAccount.account;
return account.tags.any().in(tags).and(account.zones.any().in(zones));
}
}- Java 코드로 타입 세이프한 쿼리를 작성할 수 있다.
- any() : 한개 이상.
- in() : 포함.
- 사용 예시
//detached 상태에서는 tags와 zones를 가져올 수 없기 때문에 persist 상태로 만들어주기 위해 다시 조회
Study study = studyRepository.findStudyWithTagsAndZonesByPath(studyCreatedEvent.getStudy().getPath());
//Iterable<T> findAll(Predicate var1)
Iterable<Account> accounts = accountRepository.findAll(AccountPredicate.findByTagsAndZones(study.getTags(), study.getZones()));
accounts.forEach(a ->{
if(a.isStudyCreatedByEmail()){
sendStudyCreatedNotificationEmail(study, a);
}
if(a.isStudyCreatedByWeb()){
saveCreatedStudyNotification(study, a);
}
});@Transactional(readOnly = true)
public interface StudyRepositoryExtension {
List<Study> findByKeyword(String keyword);
}- Repository의 확장 인터페이스를 생성하고, 메소드를 정의한다.
- 원래의 Repository에서 이를 상속한다.
public class StudyRepositoryExtensionImpl extends QuerydslRepositorySupport implements StudyRepositoryExtension{
public StudyRepositoryExtensionImpl() {
super(Study.class);
}
@Override
public List<Study> findByKeyword(String keyword) {
QStudy study = QStudy.study;
JPQLQuery<Study> findStudyByKeywordQuery = from(study).where(study.published.isTrue()
.and(study.title.containsIgnoreCase(keyword))
.or(study.zones.any().localNameOfCity.containsIgnoreCase(keyword))
.or(study.tags.any().title.containsIgnoreCase(keyword)))
.leftJoin(study.tags, QTag.tag).fetchJoin()
.leftJoin(study.zones, QZone.zone).fetchJoin()
.leftJoin(study.members, QAccount.account).fetchJoin()
.distinct();
return findStudyByKeywordQuery.fetch();
}
}- 클래스명은 {extensionInterface}Impl 형식.
- Qeury를 생성하고 fetch를 반환.
- 코드의 예시는 keyword를 통한 스터디 타이틀, 태그, 지역에서의 검색이다.
- N+1 Select 문제를 해결하기 위해 left 조인을 하고, fetch를 미리 불러오기 위해 fetchJoin을 했다.
- Event의 처리 전(pre), 처리 후(post), view를 불러온 후(afterCompletion) 수행할 일 지정.
@RequiredArgsConstructor
@Component
public class NotificationHandlerInterceptor implements HandlerInterceptor {
private final NotificationRepository notificationRepository;
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(modelAndView !=null && !isRedirectview(modelAndView) && authentication.getPrincipal() != null && authentication.getPrincipal() instanceof UserAccount){
Account account = ((UserAccount) authentication.getPrincipal()).getAccount();
Long count = notificationRepository.countNotificationByAccountAndChecked(account, false);
modelAndView.addObject("hasNotification", count>0);
modelAndView.addObject("numberOfNotification", count);
}
}- HandlerInterceptor 의 메소드를 구현하여 작동한다.
- Request, Response, Handler, Model, View를 조작할 수 있다.
@Override
public Page<Study> findByKeyword(String keyword, Pageable pageable) {
QStudy study = QStudy.study;
JPQLQuery<Study> findStudyByKeywordQuery = from(study).where(study.published.isTrue()
.and(study.title.containsIgnoreCase(keyword))
.or(study.zones.any().localNameOfCity.containsIgnoreCase(keyword))
.or(study.tags.any().title.containsIgnoreCase(keyword)))
.leftJoin(study.tags, QTag.tag).fetchJoin()
.leftJoin(study.zones, QZone.zone).fetchJoin()
.leftJoin(study.members, QAccount.account).fetchJoin()
.distinct();
JPQLQuery<Study> pageableQuery = getQuerydsl().applyPagination(pageable, findStudyByKeywordQuery);
QueryResults<Study> fetchResults = pageableQuery.fetchResults();
// 결과 , pageable, 결과 수(여기서는 Study의 수)
return new PageImpl<>(fetchResults.getResults(), pageable, fetchResults.getTotal());
@GetMapping("/search/study") //pageable : size, page start Index, sort 기준
public String studySearch(@PageableDefault(size = 9, page = 0, sort ="memberCount", direction = Sort.Direction.DESC) Pageable pageable, String keyword, Model model){
Page<Study> studyList = studyRepository.findByKeyword(keyword, pageable);
model.addAttribute("keyword", keyword);
model.addAttribute("studyPage", studyList);
model.addAttribute("sortProperty", pageable.getSort().toString()); // memberCount: DESC
model.addAttribute("order", pageable.getSort().toString().split(": ")[1]); // DESC
return "search-view";
}-
default size는 20, class의 property를 sort 기준으로 정함.
<div class="row justify-content-center">
<div class="col-sm-10">
<nav>
<ul class="pagination justify-content-center">
<li class="page-item" th:classappend="${!studyPage.hasPrevious()}? disabled">
<a th:href="@{'/search/study?keyword=' + ${keyword} + '&sort=' + ${sortProperty.split(':')[0]} + ','+${order}+'&page=' + ${studyPage.getNumber() - 1}}"
class="page-link" tabindex="-1" aria-disabled="true">
Previous
</a>
</li>
<li class="page-item" th:classappend="${i == studyPage.getNumber()}? active"
th:each="i: ${#numbers.sequence(0, studyPage.getTotalPages() - 1)}">
<a th:href="@{'/search/study?keyword=' + ${keyword} + '&sort=' + ${sortProperty.split(':')[0]} + ','+${order}+'&page=' + ${i}}"
class="page-link" href="#" th:text="${i + 1}">1</a>
</li>
<li class="page-item" th:classappend="${!studyPage.hasNext()}? disabled">
<a th:href="@{'/search/study?keyword=' + ${keyword} + '&sort=' + ${sortProperty.split(':')[0]} + ','+${order}+'&page=' + ${studyPage.getNumber() + 1}}"
class="page-link">
Next
</a>
</li>
</ul>
</nav>
</div>
</div><a class="dropdown-item" th:classappend="${#strings.contains(sortProperty, 'memberCount')}? active"
th:href="@{'/search/study?sort=memberCount,DESC&keyword=' + ${keyword}+'&page='+ ${studyPage.getNumber()}}">
멤버수
</a>- sortProperty(pageable.getSort) 의 형식은 property: Order
- sort 의 value 로 sort property 와 order 를 넘겨줄 수 있고, page의 값으로 page number 를 넘겨줄 수 있다.
- Page.getNumber() or Page.pageable.getNumber() => 현재 페이지
- 특정 텍스트 하이라이팅
npm install mark.js --save
<script src="/node_modules/mark.js/dist/jquery.mark.js"></script>
<script type="application/javascript">
$(function(){
var mark = function() {
// Read the keyword
var keyword = $("#keyword").text();
// Determine selected options
var options = {
"each": function(element) {
setTimeout(function() {
$(element).addClass("animate");
}, 150);
}
};
// Mark the keyword inside the context
$(".context").unmark({
done: function() {
$(".context").mark(keyword, options);
}
});
};
mark();
});
</script>- 색과 같은 style 값은 css에서 변경 가능하다.
mark {
color:red;
transition: all .5s;
}
mark.animate {
color: #000;
background-color:skyblue;
}-
../template/error/ 404.html or 5xx.html 과 같이 에러페이지 설정 가능.
-
../template/error.html로 설정 가능.
@Slf4j
@ControllerAdvice
public class ExceptionAdvice {
@ExceptionHandler
public String handleRuntimeException(@CurrentUser Account account, HttpServletRequest req, RuntimeException e){
if(account != null){
log.info("{} Requested {}", account.getNickName(), req.getRequestURI());
}
else{
log.info("Requested {}", req.getRequestURI());
}
log.error("Bad Request : ", e);
return "error";
}
}- 요청을 보낸 사람과 요청의 URI 를 로깅.
- error 레벨로 Exception 로깅.
- 설정 파일
- 우선순위를 고려
- 민감한 정보가 유출되지 않도록 고려.
- 로깅
- 모니터링 시스템과의 연동
- 민감한 데이터 로깅하지 않도록.
- 배포 환경에 알맞은 로깅 설정.
- 패키징
- 외장 톰캣에 WAR로?
- 톰캣을 내장하여 JAR로?
- 배포 방법
- CI / CD 구축




