이 글은 인프런 Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) 강의를 듣고 쓴 글입니다.
프로젝트 생성
의존성 : Spring Boot DevTools, Lombok, Spring Web, Eureka Discovery Client
user-service 라는 이름의 프로젝트 생성 후 메인 클래스에 @EnableDiscoveryClient 를 달아준다.
application.yml 파일에는 다음과 같이 작성한다. 무슨 뜻인지 모르겠으면 앞의 내용을 복습하자.
server:
port: 0
spring:
application:
name: user-service
eureka:
instance:
instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://127.0.0.1:8761/eureka
Configuration 정보 추가.
환경설정에 관한 정보 등을 실제 코드 안이 아니라 application.yml 에서 주입할 수 있다. 2가지 방법을 모두 살펴보자.
우선 application.yml 파일에 다음과 같이 추가하자.
greeting:
message: Welcome to the Simple E-commerce;
Environment 클래스 사용
Environment 클래스를 활용해서 properties, yml 파일의 내용을 가져올 수 있다.
Controller에 다음 코드를 추가하자.
private final Environment env; //의존관계 주입도 해야함. 생성자 주입 권장. 여기에는 생략함
...
@GetMapping("/welcome")
public String welcome() {
return env.getProperty("greeting.message");
}
@Value 사용
@Value 어노테이션을 사용해서 properties, yml 파일의 내용을 가져올 수 있다.
다음의 클래스를 vo라는 패키지를 생성해 만들자.
@Component
@Data
public class Greeting {
@Value("${greeting.message}")
private String message;
}
Controller에 다음 코드를 추가하자.
@GetMapping("/welcome")
public String welcome() {
// return env.getProperty("greeting.message");
return greeting.getMessage();
}
H2 database 연동
H2 database 를 다음과 같이 연동하자.
다만, H2는 용량이 굉장히 작고 가벼운 인메모리(In-memory) 데이터베이스로, 보통 학습용으로 사용한다. 학습하는 데 편하게 사용하되, 사용법을 너무 구체적으로 익힐 필요는 없다고 생각한다.
pom.xml 에 다음 코드를 추가해 의존성을 주입한다. 1.4이전 버전으로 사용해야 자동으로 데이터베이스를 생성한다고 하지만,,, 스프링 부트 3.0 이상을 사용하면 너무 낮은 버전에서 h2-console에 접근이 안되는 것 같다. 최신버전으로 설정했다.
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.220</version>
<scope>runtime</scope>
</dependency>
application.yml 에 다음 코드를 추가한다.
spring:
h2:
console:
enabled: true
settings:
web-allow-others: true
path: /h2-console
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:testdb
username: sa
password:
jpa:
hibernate:
ddl-auto: create
이러면 서비스를 실행시키고, /h2-console 로 접속하면 브라우저 상에서 데이터베이스의 콘솔이 열린다.
참고로, h2 console의 기본 데이터베이스 경로가 jdbc:h2:~/test로 되어있는데 이러면 오류가 난다. 필자도 오류가 나서 수정한 것이고 뒤에 이에 대한 설명이 있다.
회원 가입
이제 실제 로직을 처리해보자. spring을 공부해본 사람이면 전혀 어려울 부분이 없을 것이다.
Controller에서 요청을 받아오기 위한 vo 객체를 만들자.
@Data
public class RequestUser {
@NotNull(message = "Email cannot be null")
@Size(min = 2, message = "Email not be less than two characters")
@Email
private String email;
@NotNull(message = "Name cannot be null")
@Size(min = 2, message = "Name not be less than two characters")
private String name;
@NotNull(message = "Password cannot be null")
@Size(min = 8, message = "Password must be equal or grater than 8 characters")
private String pwd;
}
Validation 체크를 위한 어노테이션을 적극 활용해 이와 같은 vo 객체를 생성한다.
만약, DTO와 VO의 차이점을 모른다면 다음 게시글을 참고하자.
https://maenco.tistory.com/entry/Java-DTO%EC%99%80-VO%EC%9D%98-%EC%B0%A8%EC%9D%B4
간단히 말해, DTO는 값의 이동을 위해, VO는 값 자체를 나타내기 위해 쓴다. 혼용되기도 한다.
여기서는 RequestUser라는 VO로 값을 전달받고, 값들을 UserDto 객체로 옮겨서 서비스 내의 값의 이동을 한다.
@Data
public class UserDto {
private String email;
private String name;
private String pwd;
private String userId;
private Date cratedAt;
private String encryptedPwd;
}
encryptedPwd는 실제 비밀번호를 데이터베이스에 저장하는 것이 보안상 문제가 발생하기 때문이다.
또한, 응답을 위한 ResponseUser 라는 VO 객체도 생성하자. 여기에는 요청할 때와 다르게 패스워드가 없고 유저의 ID 값이 들어가있다. 뒤에 Service 클래스에서 유저의 ID 값을 부여하기 위한 로직이 있다.
@Data
public class ResponseUser {
private String email;
private String name;
private String userId;
}
이제 실제 회원 가입을 위한 Service 인터페이스를 다음과 같이 추가하자.
public interface UserService {
UserDto createUser (UserDto userDto);
}
이제 서비스 구현을 위한 Repository와 Entity를 구현하자.
Repository는 Spring Data Jpa를 활용한다. 다음과 같은 의존성을 추가하자.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
이제 Repository 구현을 위한 다음과 같은 인터페이스를 구현하자.
public interface UserRepository extends CrudRepository<UserEntity, Long> {
}
데이터베이스 테이블 생성을 위한 Entity를 다음과 같이 생성하자.
@Data
@Entity
@Table(name = "users")
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
@Column(nullable = false, length = 50)
private String email;
@Column(nullable = false, length = 50)
private String name;
@Column(nullable = false, unique = true)
private String userId;
@Column(nullable = false, unique = true)
private String encryptedPwd;
}
어... 따라오기 어렵다구요? 무슨 소린지 모르겠다구요? 여기있지 말고 빨리 spring 기초부터 하러 가세요
우리는 값을 담은 클래스를 DTO, VO, Entity로 총 3개 구현했다. 값들은 이 세 개의 클래스를 통해 전달되고 변환되어야 하는데, 이 때 굉장히 편리한 의존성으로 ModelMapper가 있다.
다음과 같은 의존성을 pom.xml에 추가하자.
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>2.4.5</version>
</dependency>
ModelMapper에 관한 자세한 내용은 다음 블로그 글을 참고하자.
https://squirmm.tistory.com/entry/Spring-modelMapper
이제 실제 Controller에서의 함수와 Service 클래스를 다음과 같이 작성하자.
@PostMapping("/users")
public ResponseEntity createUser(@RequestBody RequestUser user) {
ModelMapper mapper = new ModelMapper();
mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
UserDto userDto = mapper.map(user, UserDto.class);
userService.createUser(userDto);
ResponseUser responseUser = mapper.map(userDto, ResponseUser.class);
return ResponseEntity.status(HttpStatus.CREATED).body(responseUser);
}
model mapper를 통해 RequestUser VO 객체를 DTO 객체에 매핑시킨다. 여기서 userDto에는 userId, createdAt, encryptedPwd 값은 비어있을 것이다. Service 클래스에서 처리한다. Service 로직을 끝내고, 응답을 위한 DTO 객체를 ResponseUser VO 객체에 매핑시켜 응답한다.
주의할 점은, 응답코드로 단순히 200이라는 코드보다는, 생성되었다는 201 코드를 응답하도록 하자.
이제 Service 클래스 차례이다.
@Service
public class UserServiceImpl implements UserService{
@Autowired
UserRepository userRepository;
@Override
public UserDto createUser(UserDto userDto) {
userDto.setUserId(UUID.randomUUID().toString());
ModelMapper mapper = new ModelMapper();
mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
UserEntity userEntity = mapper.map(userDto, UserEntity.class);
userEntity.setEncryptedPwd("encrypted_password");
userRepository.save(userEntity);
UserDto returnUserDto = mapper.map(userEntity, UserDto.class);
return returnUserDto;
}
}
우선, UserId를 랜덤한 문자열로 처리하고, encryptedPwd 값은 일단 기본값으로 넣어두자 (미완성)
그 후 save 함수를 호출하면 데이터베이스에 저장된다.
이제 서비스를 실행하고 요청을 날려보자.
유저의 ID 값이 잘 부여되어 반환되는 것이 확인되었다.
H2 데이터베이스 콘솔에도 접속해서 확인해보자.
Spring Security 연동
이제 비밀번호 등의 암호화를 위해 Spring Security를 사용하자. Authentication과 Authorization, 즉 인증과 권한 부여를 한다.
WebSecurity Configuration 설정
다음과 같이 spring security 를 dependency에 추가한다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
이제 다음과 같은 Class를 생성한다.
@Configuration
@EnableWebSecurity
public class WebSecurity extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.authorizeRequests().antMatchers("/users/**").permitAll();
http.headers().frameOptions().disable();
}
}
WebSecurityConfigurerAdapter를 상속받고, 그 중에서 HttpSecurity를 매개변수로 가지는 configure 함수를 오버라이딩한다. 설정은 일단 다음 3가지를 한다.
- csrf 공격이 일어나지 않는다고 가정한다. csrf에 대한 자세한 정보는 다음 블로그 글을 참고한다. https://itstory.tk/entry/CSRF-%EA%B3%B5%EA%B2%A9%EC%9D%B4%EB%9E%80-%EA%B7%B8%EB%A6%AC%EA%B3%A0-CSRF-%EB%B0%A9%EC%96%B4-%EB%B0%A9%EB%B2%95
- /user/** 의 endpoint를 가진 요청은 모두 허용한다.
- H2 데이터베이스의 콘솔을 보기 위해 frameOptions() 도 disable 시킨다.
단, 이와 같이 WebSecurityConfigurerAdapter를 상속받는 방법은 현재 Deprecate 되었다.
스프링 부트 3.0 부터는 아예 사용이 불가능하고, 2.7 이상은 사용은 가능하다. 나는 사용이 가능한 상태이긴 하다.
현재는 Security Filter Chain을 직접 만들고 그 Chain을 직접 빈으로 등록시켜야 한다. 다음과 같이 고칠 수 있다.
public class WebSecurity {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests().antMatchers("/users/**").permitAll()
.and()
.headers().frameOptions().disable();
return http.build();
}
}
두 방법이 심하게 차이가 나지 않는다. 강의를 따라가면서 배울 점도 있기에, 매번 강의대로도 해보고, 최신버전으로도 해보는 것으로 하겠다.
검색을 했을 때 나오는 거의 모든 코드는 스프링 부트 3.0을 기준으로, 즉 filter를 bean으로 올리는 방법으로 하고 있다. 내가 사용하는 스프링 부트 2.7에서는 스프링부트 3.0이상과 똑같이 구현을 할 수 없다. 여러 메소드들이 재정의되었고 예전 코드는 deprecated 되었다. 즉 2.7버전은 예전 방법은 deprecated 되었지만, 이후 버전의 코드와 호환이 되지 않는 아주 애매한 버전이다.
따라서 3.1.2 버전으로 올려서 공부하며 코드를 짜는 것으로 결정했다.
Password encode 를 위한 BCryptPasswordEncoder 빈 정의
BCryptPasswordEncoder 는 패스워드 인코더 중에서도, 해시함수를 여러 번 적용하고, Salt 값을 적용해 해시함수 사전 공격을 최소화한 것이다.
일단, 이걸 스프링 빈으로 등록해야 하기 때문에, 메인 클래스에 다음과 같이 작성한다.
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
물론, 다른 설정 파일에서 해도 상관없다.
이제 UserServiceImpl 클래스에서 이것을 주입하고 아래와 같이 구현하자.
@Override
public UserDto createUser(UserDto userDto) {
userDto.setUserId(UUID.randomUUID().toString());
ModelMapper mapper = new ModelMapper();
mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
UserEntity userEntity = mapper.map(userDto, UserEntity.class);
userEntity.setEncryptedPwd(passwordEncoder.encode(userDto.getPwd()));
userRepository.save(userEntity);
UserDto returnUserDto = mapper.map(userEntity, UserDto.class);
return returnUserDto;
}
encryptedPwd 설정하는 부분이 달라졌다.
이제 새롭게 회원가입을 해보자. 각각 다른 email과 name으로, 같은 password로 2명을 회원가입했다.
두 사람의 password는 같지만, encryptedPwd 는 다르다. 원리를 살펴보자.
패스워드는 $ 를 기준으로 세 부분으로 나뉜다.
첫 번째 부분은 BCryptPasswordEncoder의 버전이다.
두 번째 부분은 해시함수 적용 횟수이다.
세 번째 부분은 Salt 값과 암호화된 최종 결과이다. 주어진 패스워드에 랜덤한 Salt라는 랜덤값을 붙여서 10번 해시함수를 적용한 것이 세 번째 부분이다.
BCryptPasswordEncoder의 암호화가 강한 이유는 적용 횟수와 랜덤한 Salt값을 붙여서 해시함수를 적용시키기 때문인데, 정작 횟수와 Salt값 모두 떡하니 공개되어 있는 꼴이다. 이상하다고 느낄 수 있다.
해시함수를 공격하는 원리는 사전 공격이다. 암호가 될만한 것들을 해시함수에 적용시켜 전수비교를 하는 것이다. 여기서 포인트는 전수비교이다. 공격자는 한 번 공격을 시도할 때 모든 비밀번호와 대조시켜, 하나라도 맞아 떨어지면 공격 성공인 것이다.
하지만, 각자 Salt라는 랜덤값을 가지고 있고, 해시함수를 각기 다른 횟수만큼 적용시켜 놓으면 모든 사람을 대상으로 공격을 하지 못한다. 어떤 한 사람을 대상으로 공격해야 하는 것이다.
전수비교에서는 공격이 통할 확률이 굉장히 높지만, 표적 공격에서는 확률이 거의 없다.
따라서 해시함수 적용 횟수나 Salt 값이 공개되어도, 전수비교를 막았다는 데에서 의의가 있는 것이다.
하지만 그 Salt 값 마저도 숨기면 암호화에 도움이 될 것 같지 않은가? 이렇게 랜덤값을 부여하고 그 값을 숨기는 것은 Pepper 라고 한다. (소금보다 더 매운 후추를 뿌려버리는 것!)
'[MSA] Spring Cloud로 개발하는 마이크로서비스 애플리케이션' 카테고리의 다른 글
섹션 6 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.08.04 |
---|---|
섹션 5 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.07.28 |
섹션 3 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.07.21 |
섹션 2 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (2) | 2023.07.21 |
섹션 1 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.07.20 |