이번 글에서는 Spring Security를 이용하여 회원가입 및 로그인을 구현해보도록 하겠습니다.
전체 코드는 깃헙을 참고하시길 바랍니다.
- 개발환경
- IntelliJ 2019.02
- Java 11
- SpringBoot 2.1.9
- Gradle 5.6
- 라이브러리 일부
- org.springframework.boot:spring-boot-starter-web:2.1.9.RELEASE
- org.projectlombok:lombok:1.18.10
- org.springframework.boot:spring-boot-starter-data-jpa:2.1.9.RELEASE
- org.springframework.boot:spring-boot-starter-thymeleaf:2.1.9.RELEASE
- org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.0.4.RELEASE
- org.springframework.boot:spring-boot-starter-security:2.1.9.RELEASE
[ 프로젝트 구조 ]
1. 의존성 추가
Spring Security를 사용하려면, 의존성을 추가해야합니다.
뿐만 아니라, Thymeleaf에서 Spring Security 통합 모듈을 사용하기 위한 의존성도 추가해줘야 합니다. ( 참고 )
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
2. Spring Security 설정
먼저 이 글의 핵심인 Spring Security를 설정해보도록 하겠습니다.
Spring Security는 FilterChainProxy라는 이름으로 내부에 여러 Filter들이 동작하고 있습니다. ( 참고 )
그래서 간단한 구현단계에서는 별도의 로직을 작성하지 않아도 설정만으로 로그인/로그아웃 등의 처리가 가능합니다.
설정은 WebSecurityConfigurerAdapter라는 클래스를 상속받은 클래스에서 메서드를 오버라이딩하여 조정할 수 있는데요, 그 클래스를 구현해보겠습니다.
src/main/java/com/victolee/signuplogin/config/SecurityConfig.java
package com.victolee.signuplogin.config;
import com.victolee.signuplogin.service.MemberService;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private MemberService memberService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) throws Exception
{
// static 디렉터리의 하위 파일 목록은 인증 무시 ( = 항상통과 )
web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 페이지 권한 설정
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/myinfo").hasRole("MEMBER")
.antMatchers("/**").permitAll()
.and() // 로그인 설정
.formLogin()
.loginPage("/user/login")
.defaultSuccessUrl("/user/login/result")
.permitAll()
.and() // 로그아웃 설정
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
.logoutSuccessUrl("/user/logout/result")
.invalidateHttpSession(true)
.and()
// 403 예외처리 핸들링
.exceptionHandling().accessDeniedPage("/user/denied");
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(memberService).passwordEncoder(passwordEncoder());
}
}
- @EnableWebSecurity
- @Configuration 클래스에 @EnableWebSecurity 어노테이션을 추가하여 Spring Security 설정할 클래스라고 정의합니다.
- 설정은 WebSebSecurityConfigurerAdapter 클래스를 상속받아 메서드를 구현하는 것이 일반적인 방법입니다.
- WebSecurityConfigurerAdapter 클래스
- WebSecurityConfigurer 인스턴스를 편리하게 생성하기 위한 클래스입니다.
- passwordEncoder()
- BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 객체입니다.
- Service에서 비밀번호를 암호화할 수 있도록 Bean으로 등록합니다.
다음으로 configure() 메서드를 오버라이딩하여, Security 설정을 잡아줍니다.
- configure(WebSecurity web)
- WebSecurity는 FilterChainProxy를 생성하는 필터입니다.
- web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
- 해당 경로의 파일들은 Spring Security가 무시할 수 있도록 설정합니다.
- 즉, 이 파일들은 무조건 통과하며, 파일 기준은 resources/static 디렉터리입니다. ( css, js 등의 디렉터리를 추가하진 않았습니다. )
- configure(HttpSecurity http)
- HttpSecurity를 통해 HTTP 요청에 대한 웹 기반 보안을 구성할 수 있습니다.
- authorizeRequests()
- HttpServletRequest에 따라 접근(access)을 제한합니다.
- antMatchers() 메서드로 특정 경로를 지정하며, permitAll(), hasRole() 메서드로 역할(Role)에 따른 접근 설정을 잡아줍니다. 여기서 롤은 권한을 의미합니다. 즉 어떤 페이지는 관리지만 접근해야 하고, 어떤 페이지는 회원만 접근해야할 때 그 권한을 부여하기 위해 역할을 설정하는 것입니다. 예를 들어,
- .antMatchers("/admin/**").hasRole("ADMIN")
- /admin 으로 시작하는 경로는 ADMIN 롤을 가진 사용자만 접근 가능합니다.
- .antMatchers("/user/myinfo").hasRole("MEMBER")
- /user/myinfo 경로는 MEMBER 롤을 가진 사용자만 접근 가능합니다.
- .antMatchers("/**").permitAll()
- 모든 경로에 대해서는 권한없이 접근 가능합니다.
- .anyRequest().authenticated()
- 모든 요청에 대해, 인증된 사용자만 접근하도록 설정할 수도 있습니다. ( 예제에는 적용 안함 )
- formlogin()
- form 기반으로 인증을 하도록 합니다. 로그인 정보는 기본적으로 HttpSession을 이용합니다.
- /login 경로로 접근하면, Spring Security에서 제공하는 로그인 form을 사용할 수 있습니다.
- .loginPage("/user/login")
- 기본 제공되는 form 말고, 커스텀 로그인 폼을 사용하고 싶으면 loginPage() 메서드를 사용합니다.
- 이 때 커스텀 로그인 form의 action 경로와 loginPage()의 파라미터 경로가 일치해야 인증을 처리할 수 있습니다. ( login.html에서 확인 )
- .defaultSuccessUrl("/user/login/result")
- 로그인이 성공했을 때 이동되는 페이지이며, 마찬가지로 컨트롤러에서 URL 매핑이 되어 있어야 합니다.
- .usernameParameter("파라미터명")
- 로그인 form에서 아이디는 name=username인 input을 기본으로 인식하는데, usernameParameter() 메서드를 통해 파라미터명을 변경할 수 있습니다. ( 예제에는 적용 안함 )
- logout()
- 로그아웃을 지원하는 메서드이며, WebSecurityConfigurerAdapter를 사용할 때 자동으로 적용됩니다.
- 기본적으로 "/logout"에 접근하면 HTTP 세션을 제거합니다.
- .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
- 로그아웃의 기본 URL(/logout) 이 아닌 다른 URL로 재정의합니다.
- .invalidateHttpSession(true)
- HTTP 세션을 초기화하는 작업입니다.
- deleteCookies("KEY명")
- 로그아웃 시, 특정 쿠기를 제거하고 싶을 때 사용하는 메서드입니다. ( 예제에는 적용안함 )
- .exceptionHandling().accessDeniedPage("/user/denied");
- 예외가 발생했을 때 exceptionHandling() 메서드로 핸들링할 수 있습니다.
- 예제에서는 접근권한이 없을 때, 로그인 페이지로 이동하도록 명시해줬습니다
- configure(AuthenticationManagerBuilder auth)
- Spring Security에서 모든 인증은 AuthenticationManager를 통해 이루어지며 AuthenticationManager를 생성하기 위해서는 AuthenticationManagerBuilder를 사용합니다.
- 로그인 처리 즉, 인증을 위해서는 UserDetailService를 통해서 필요한 정보들을 가져오는데, 예제에서는 서비스 클래스(memberService)에서 이를 처리합니다.
- 서비스 클래스에서는 UserDetailsService 인터페이스를 implements하여, loadUserByUsername() 메서드를 구현하면 됩니다.
- 비밀번호 암호화를 위해, passwordEncoder를 사용하고 있습니다.
코드는 간단한데, 풀어쓰니 조금 복잡해진 것 같습니다.
특히 HttpSecurity 부분이 조금 복잡한데, 위 링크에서 API들을 참고하시면 좋을 것 같습니다.
3. 컨트롤러 / 서비스 / 도메인 등 구현
다음으로 각 Layer들을 구현해보도록 하겠습니다.
Service를 유념해서 봐주시길 바라며, 로그인, 로그아웃에 대한 로직이 없는 것도 살펴보시길 바랍니다.
1) src/main/java/com/victolee/signuplogin/controller/MemberController.java
package com.victolee.signuplogin.controller;
import com.victolee.signuplogin.dto.MemberDto;
import com.victolee.signuplogin.service.MemberService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
@AllArgsConstructor
public class MemberController {
private MemberService memberService;
// 메인 페이지
@GetMapping("/")
public String index() {
return "/index";
}
// 회원가입 페이지
@GetMapping("/user/signup")
public String dispSignup() {
return "/signup";
}
// 회원가입 처리
@PostMapping("/user/signup")
public String execSignup(MemberDto memberDto) {
memberService.joinUser(memberDto);
return "redirect:/user/login";
}
// 로그인 페이지
@GetMapping("/user/login")
public String dispLogin() {
return "/login";
}
// 로그인 결과 페이지
@GetMapping("/user/login/result")
public String dispLoginResult() {
return "/loginSuccess";
}
// 로그아웃 결과 페이지
@GetMapping("/user/logout/result")
public String dispLogout() {
return "/logout";
}
// 접근 거부 페이지
@GetMapping("/user/denied")
public String dispDenied() {
return "/denied";
}
// 내 정보 페이지
@GetMapping("/user/info")
public String dispMyInfo() {
return "/myinfo";
}
// 어드민 페이지
@GetMapping("/admin")
public String dispAdmin() {
return "/admin";
}
}
컨트롤러에서 특별한 것은 없습니다.
2) src/main/java/com/victolee/signuplogin/service/MemberService.java
package com.victolee.signuplogin.service;
import com.victolee.signuplogin.domain.Role;
import com.victolee.signuplogin.domain.entity.MemberEntity;
import com.victolee.signuplogin.domain.repository.MemberRepository;
import com.victolee.signuplogin.dto.MemberDto;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
@AllArgsConstructor
public class MemberService implements UserDetailsService {
private MemberRepository memberRepository;
@Transactional
public Long joinUser(MemberDto memberDto) {
// 비밀번호 암호화
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
memberDto.setPassword(passwordEncoder.encode(memberDto.getPassword()));
return memberRepository.save(memberDto.toEntity()).getId();
}
@Override
public UserDetails loadUserByUsername(String userEmail) throws UsernameNotFoundException {
Optional<MemberEntity> userEntityWrapper = memberRepository.findByEmail(userEmail);
MemberEntity userEntity = userEntityWrapper.get();
List<GrantedAuthority> authorities = new ArrayList<>();
if (("admin@example.com").equals(userEmail)) {
authorities.add(new SimpleGrantedAuthority(Role.ADMIN.getValue()));
} else {
authorities.add(new SimpleGrantedAuthority(Role.MEMBER.getValue()));
}
return new User(userEntity.getEmail(), userEntity.getPassword(), authorities);
}
}
- joinUser()
- 회원가입을 처리하는 메서드이며, 비밀번호를 암호화하여 저장합니다.
- loadUserByUsername()
- 상세 정보를 조회하는 메서드이며, 사용자의 계정정보와 권한을 갖는 UserDetails 인터페이스를 반환해야 합니다.
- 매개변수는 로그인 시 입력한 아이디인데, 엔티티의 PK를 뜻하는게 아니고 유저를 식별할 수 있는 어떤 값을 의미합니다. Spring Security에서는 username라는 이름으로 사용합니다.
- 예제에서는 아이디가 이메일이며, 로그인을 하는 form에서 name="username"으로 요청해야 합니다.
- authorities.add(new SimpleGrantedAuthority());
- 롤을 부여하는 코드입니다. 롤 부여 방식에는 여러가지가 있겠지만, 회원가입할 때 Role을 정할 수 있도록 Role Entity를 만들어서 매핑해주는 것이 좋은 방법인것 같습니다. ( 참고 )
- 예제에서는 복잡성을 줄이기 위해, 아이디가 "admin@example.com"일 경우에 ADMIN 롤을 부여했습니다.
- new User()
- return은 SpringSecurity에서 제공하는 UserDetails를 구현한 User를 반환합니다. ( org.springframework.security.core.userdetails.User )
- 생성자의 각 매개변수는 순서대로 아이디, 비밀번호, 권한리스트입니다.
3) src/main/java/com/victolee/signuplogin/domain/Role.java
package com.victolee.signuplogin.domain;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public enum Role {
ADMIN("ROLE_ADMIN"),
MEMBER("ROLE_MEMBER");
private String value;
}
- Service에서 사용하는 Enum객체입니다.
4) src/main/java/com/victolee/signuplogin/domain/repository/MemberRepository.java
package com.victolee.signuplogin.domain.repository;
import com.victolee.signuplogin.domain.entity.MemberEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<MemberEntity, Long> {
Optional<MemberEntity> findByEmail(String userEmail);
}
- Email을 Where 조건절로 하여, 데이터를 가져올 수 있도록 findByEmail() 메서드를 정의했습니다.
5) src/main/java/com/victolee/signuplogin/domain/entity/MemberEntity.java
package com.victolee.signuplogin.domain.entity;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "member")
public class MemberEntity {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
private Long id;
@Column(length = 20, nullable = false)
private String email;
@Column(length = 100, nullable = false)
private String password;
@Builder
public MemberEntity(Long id, String email, String password) {
this.id = id;
this.email = email;
this.password = password;
}
}
6) src/main/java/com/victolee/signuplogin/dto/MemberDto.java
package com.victolee.signuplogin.dto;
import com.victolee.signuplogin.domain.entity.MemberEntity;
import lombok.*;
import java.time.LocalDateTime;
@Getter
@Setter
@ToString
@NoArgsConstructor
public class MemberDto {
private Long id;
private String email;
private String password;
private LocalDateTime createdDate;
private LocalDateTime modifiedDate;
public MemberEntity toEntity(){
return MemberEntity.builder()
.id(id)
.email(email)
.password(password)
.build();
}
@Builder
public MemberDto(Long id, String email, String password) {
this.id = id;
this.email = email;
this.password = password;
}
}
Entity와 Dto에서도 특별한 것은 없습니다.
4. 퍼블리싱
마지막으로 HTML을 구현해보도록 하겠습니다.
테스트를 위한 페이지들이 많으며, 핵심 파일은 index.html, login.html 입니다.
1) src/main/resources/templates/admin.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>어드민</title>
</head>
<body>
<h1>어드민 페이지입니다.</h1>
<hr>
</body>
</html>
2) src/main/resources/templates/denied.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>접근 거부</title>
</head>
<body>
<h1>접근 불가 페이지입니다.</h1>
<hr>
</body>
</html>
3) src/main/resources/templates/index.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>메인</title>
</head>
<body>
<h1>메인 페이지</h1>
<hr>
<a sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
<a sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
<a sec:authorize="isAnonymous()" th:href="@{/user/signup}">회원가입</a>
<a sec:authorize="hasRole('ROLE_MEMBER')" th:href="@{/user/info}">내정보</a>
<a sec:authorize="hasRole('ROLE_ADMIN')" th:href="@{/admin}">어드민</a>
</body>
</html>
- sec:authorize를 사용하여, 사용자의 Role에 따라 보이는 메뉴를 다르게 합니다.
- isAnonymous()
- 익명의 사용자일 경우, 로그인, 회원가입 버튼을 노출합니다.
- isAuthenticated()
- 인증된 사용자일 경우, 로그아웃 버튼을 노출줍니다.
- hasRole()
- 특정 롤을 가진 사용자에 대해, 메뉴를 노출합니다.
4) src/main/resources/templates/login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인</h1>
<hr>
<form action="/user/login" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="text" name="username" placeholder="이메일 입력해주세요">
<input type="password" name="password" placeholder="비밀번호">
<button type="submit">로그인</button>
</form>
</body>
</html>
- <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
- form에 히든 타입으로 csrf 토큰 값을 넘겨줍니다.
- Spring Security가 적용되면 POST 방식으로 보내는 모든 데이터는 csrf 토큰 값이 필요합니다. ( 뒤에서 살펴 볼 join.html에서는 편리한 방법을 사용하고 있습니다. )
- 토큰 값이 없는 상태에서 form 전송을 할 경우, 컨트롤러에 POST 메서드를 매핑할 수 없다는 에러가 발생합니다.
- error : HttpRequestMethodNotSupportedException: Request method 'POST'
- <input type="text" name="username" placeholder="이메일 입력해주세요">
- 로그인 시 아이디의 name 애트리뷰트 값은 username이어야 합니다.
5) src/main/resources/templates/loginSuccess.html
<!DOCTYPE html>
<html lang="en" xmlns:sec="" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>로그인 성공</title>
</head>
<body>
<h1>로그인 성공!!</h1>
<hr>
<p>
<span sec:authentication="name"></span>님 환영합니다~
</p>
<a th:href="@{'/'}">메인으로 이동</a>
</body>
</html>
- sec:authentication="name"
- useranme 값을 가져옵니다. ( 참고 )
6) src/main/resources/templates/logout.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>로그아웃</title>
</head>
<body>
<h1>로그아웃 처리되었습니다.</h1>
<hr>
<a th:href="@{'/'}">메인으로 이동</a>
</body>
</html>
7) src/main/resources/templates/myinfo.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>내정보</title>
</head>
<body>
<h1>내정보 확인 페이지입니다.</h1>
<hr>
</body>
</html>
8) src/main/resources/templates/signup.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>회원가입 페이지</title>
</head>
<body>
<h1>회원 가입</h1>
<hr>
<form th:action="@{/user/signup}" method="post">
<input type="text" name="email" placeholder="이메일 입력해주세요">
<input type="password" name="password" placeholder="비밀번호">
<button type="submit">가입하기</button>
</form>
</body>
</html>
- 로그인 페이지와 달리 input hidden 타입으로 csrf 토큰 값을 넘겨주지 않고 있는데요, th:action을 사용하면 Thymeleaf가 csrf 토큰 값을 자동으로 추가해주므로 편리합니다.
5. 테스트
이제 애플리케이션을 실행하고, 회원가입/로그인/로그아웃 및 권한에 따른 메뉴가 잘 보이는지 확인하시길 바랍니다.
- 1~4
- 어드민 계정( admin@example.com )으로 회원가입 및 로그인을 진행합니다.
- 5~6
- 메인 페이지에서 "어드민" 버튼이 노출되는 것을 확인하고, 접근을 해봅니다.
- 7
- 브라우저 URL에 "/user/myinfo"를 입력하여 멤버만 접근할 수 있는 페이지에 접근 불가능한지 확인합니다.
- 8
- 로그아웃을 합니다.
다음으로 어드민 이메일이 아닌 아이디로 가입하여 같은 테스트를 해봅니다. ( MEMBER 롤을 가지므로, admin 페이지에는 접근 불가 )
이상으로 Spring Security를 이용하여 회원가입/로그인/로그아웃을 진행해보았습니다.
Security는 설정만 해주면 Filter, Interceptor에서 인증을 해주기 때문에 한 번 작성하고 나면, 딱히 건드리지 않는 부분이긴합니다.
본문에서는 다루지 않았지만, 커스텀 로그인 성공, 로그인 실패, 로그아웃 처리 등을 할 수 있는 handler들이 있습니다.
이 내용도 다루고 싶었지만, 글이 길어지는 관계로 링크로 대체하겠습니다.
AuthenticationSuccessHandler / AuthenticationFailureHandler ( 참고 )
SimpleUrlLogoutSuccessHandler ( 참고 )
[ 참고자료 ]
https://spring.io/guides/topicals/spring-security-architecture#_web_security
https://galid1.tistory.com/576
https://xmfpes.github.io/spring/spring-security/
http://progtrend.blogspot.com/2018/07/spring-boot-security.html
출처 : victorydntmd.tistory.com/328
'백엔드 개발 놀이터 > Spring' 카테고리의 다른 글
SpringBoot Profile - local,dev,production 나누기 (0) | 2020.06.15 |
---|---|
[JPA] nullable = false와 @NotNull의 차이점 (0) | 2020.05.19 |
Java Map - MyBatis 데이터 HashMap으로 받기 (0) | 2020.04.21 |
[Lombok] 롬복 설치 및 사용법 (0) | 2020.04.08 |
이클립스(Eclipse)에 SVN(SubVersion) 설치하기 (0) | 2020.04.08 |