Spring boot 기반 REST API 제작 (3)

Asterisk
12 min readOct 10, 2018

--

PREV: Spring boot 기반 REST API 제작 (2)

2017년에 Spring boot 기반 REST API 제작이라는 거창한 이름으로 블로깅을 시작했었다. 그리고 그와 관련된 아티클을 두 개밖에 작성하지 않고 그 자체를 잊어버렸다는걸 최근에 자각했다.
왜 중단했었는지 기억이 나지 않지만, 검색으로 유입되어 (1), (2)를 읽었는데 아무리 찾아도 (3)이 없어 난감해했을 독자들에게 죄스러운 마음이 남는다. 그런 의미에서 양심을 세탁하기 위해 글을 이어나가보도록 하자.

HTTP의 액세스 제어와 인증을 위한 프레임워크는 RFC 7235에 정의되어 있고, 가장 쉬운 인증 방식인 Basic 인증 방식은 RFC 7617에 정의되어있다.

RFC 7235에 정의되어 있는 방식은 Authorization Header에 <type> <credential>을 담아 전송하는 형태이다.
(왜 헤더네임이 Authentication이 아니라 Authorization인지는 미스테리)

Basic 인증 방식의 경우 type은 Basic credential은 username:password 형태의 문자열을 Base64 인코딩한 문자열 YWxpY2U6d29uZGVybGFuZA==이다.

예를 들어 username이 alice이고 password가 wonderland라면 아래와 같이 Authorization Header를 추가해주면 된다.

curl -X "GET" "https://your-domain/users/me/" \
-H 'Authorization: Basic YWxpY2U6d29uZGVybGFuZA=='

물론 Header의 creadential을 디코딩하면 사용자의 username과 password를 노출되기 때문에 반드시 SSL을 사용하여야한다.

그럼, 사용법을 알았으니 Basic 인증으로 동작하도록 (2)에서 작성한 코드를 수정해보자.

우선 인증에 username과 password가 필요하기 때문에 User 클래스를 다음과 같이 수정한다.

@Entity
public class User {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
private Integer id;
@Column(unique = true)
private String username;
private String password;

protected User() {}

public User(String username, String password) {
this.username = username;
this.password = password;
}

public Integer getId() {
return id;
}

public void setId(Integer id) {
this.id = id;
}

public String getUsername() {
return username;
}

public void setUsername(String username) {
this.username = username;
}

public String getPassword() {
return password;
}

public void setPassword(String password) {
this.password = password;
}

@Override
public String toString() {
return String.format("User[id='%d', username='%s']", id, username);
}
}

달라진 부분은 password가 추가됐다는 점과 name이 username으로 변경되면서 해당 컬럼에 대해 unique 제약조건이 추가되었다는 점이다.

UserRepository는 다음과 같이 CrudRepository에서 JpaRepository로 변경 후 username과 password로 사용자를 검색하는 findUsernameAndPassword 메서드 선언을 추가한다.
(JpaRepository는 메서드명으로부터 자동적으로 쿼리를 생성하는 방식을 지원하기 때문에 findUsernameAndPassword 메서드의 경우 선언 이외의 다른 정의를 해주지 않아도 된다. 그 방식은 여기를 참고하자.)

public interface UserRepository extends JpaRepository<User, Integer> {
User findByUsernameAndPassword(String username, String password);
}

(2)에서는 controller에서 repository를 직접 가져다썼지만 CRUD 이외의 기능을 필요로 한다면 service 레이어를 추가적으로 구성하여 controller/service/persistence 형태로 레이어링 하는 것을 권장한다. 그렇지 않으면 controller에 모든 로직이 존재하는 손대기 싫은 구조가 되어버리고 만다. (마치 기획, 개발, QA, 운영, CS를 다 뒤집어 쓴 여러분처럼 말이다.)

현단계에선 controller에서는 입력에 대한 검증, 출력에 대한 포맷팅
service에서는 기능을 구현한다는 생각으로 나눈다고 생각하자.

기본적인 가입, 인증, 정보조회, 업데이트, 탈퇴 기능을 위해서는 service 내에 다음과 같은 메서드들이 필요하다. 여기서 token은 Authorization 헤더를 통해 전달된 인증을 위한 문자열이다.

public interface UserService {
// 가입
User join(String username, String password);
// 인증 & 개인정보 조회
User authentication(String token);
// 비밀번호 변경
User updatePassword(String token, String password);
// 탈퇴
void withdraw(String token);
}

위의 인터페이스에 대한 구현은 다음과 같다.

@Service
public class UserServiceImpl implements UserService {

private UserRepository userRepository;

@Autowired
public UserServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}

// 가입
@Override
public User join(String username, String password) {
return userRepository.save(new User(username, password));
}

// 인증 & 개인정보 조회
@Override
public User authentication(String token) {
// authorization으로부터 type과 credential을 분리
String[] split = token.split(" ");
String type = split[0];
String credential = split[1];

User user = null;

if ("Basic".equalsIgnoreCase(type)) {
// credential을 디코딩하여 username과 password를 분리
String decoded = new String(Base64Utils.decodeFromString(credential));
String[] usernameAndPassword = decoded.split(":");

user = userRepository.findByUsernameAndPassword(usernameAndPassword[0], usernameAndPassword[1]);
}
return user;
}

// 비밀번호 업데이트
@Override
public User updatePassword(String token, String password) {
User user = this.authentication(token);
user.setPassword(password);
return userRepository.save(user);
}

// 탈퇴
@Override
public void withdraw(String token) {
User user = this.authentication(token);
userRepository.delete(user);
}
}

회원가입, 개인정보 조회, 비밀번호 업데이트, 탈퇴 기능의 구현에서 회원 인증이 필요한 개인정보 조회, 비밀번호 업데이트, 탈퇴의 경우 공통적으로 Authorization 헤더를 통해 전달된 값을 검증하고 그에 맞는 user 정보를 확인하는 과정이 포함되어있다.
이처럼 공통적인 역할을 하는 부분은 filterinterceptor로 작성하여 사전에 처리할 수 있다. (하지만, 아직 미비한 부분이 너무 많은 관계로 구체적인 사용법을 설명하는 것은 더 나중에 알아볼 예정이다.)

위의 UserService를 통해 회원가입, 개인정보 조회, 비밀번호 업데이트, 탈퇴 기능을 수행하도록 UserController를 수정하면 다음과 같다.

@RestController
@RequestMapping(value = "/users")
public class UserController {

private final UserService userService;

@Autowired
public UserController(UserService userService) {
this.userService = userService;
}


// 사용자 username과 password을 입력받아 새로운 User를 생성하고 그 결과를 반환
@PostMapping
public User create(@RequestParam String username, String password) {
return userService.join(username, password);
}

// 자신의 정보를 반환
@GetMapping(value = "/me")
public User getMe(@RequestHeader String authorization) {

return userService.authentication(authorization);
}

// 자신의 비밀번호를 갱신한 뒤 그 결과를 반환
@PutMapping(value = "/me")
public User updatePassword(@RequestHeader String authorization, @RequestParam String password) {
return userService.updatePassword(authorization, password);
}

// 탈퇴
@DeleteMapping
public void withdraw(@RequestHeader String authorization) {
userService.withdraw(authorization);
}
}

이제 작성한 코드를 IDE에서 run하거나 mvn spring-boot:run 명령으로 실행시킨 다음 어떻게 동작하는지 확인해보자.

# 회원 가입
curl -X "POST" "http://localhost:8080/users" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-—data-urlencode "username=alice" \
—-data-urlencode "password=wonderland"

위와 같이 회원가입 시도하면 중복된 username이 아닌 이상 가입된 user의 정보와 함께 200 OK 응답이 내려올 것이다.
(중복된 username일 경우에는 별다른 예외 처리를 안해놓았기 때문에 exception과 500 Internal Server Error 응답이 내려온다.)

가입을 했으니 회원의 username과 password를 바탕으로 Basic 인증 토큰을 만들어서 조회와 비밀번호 변경을 시도해보자.

# 회원 정보 조회
curl "http://localhost:8080/users/me" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H Authorization: 'Basic YWxpY2U6d29uZGVybGFuZA=='
# 비밀번호 변경
curl -X "PUT" "http://localhost:8080/users/me" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H Authorization: 'Basic YWxpY2U6d29uZGVybGFuZA=='
--data-urlencode "password=neverland"

올바른 token을 사용했다면 회원정보 조회 및 비밀번호 변경이 실행된 결과와 함께 200 OK 응답이 내려올 것이고 그렇지 않으면 아무런 내용이 없이 200 OK 응답이 내려온다.

원칙적으로 Authorization 헤더를 통해 전달된 token의 값이 유효하지 않다면 401 Unauthorized응답 통해 클라이언트에 그 사실을 알려야하지만 현재 작성된 코드는 예외처리가 되어있지 않기 때문에 그렇게 동작하지 않고있다.
그렇기 때문에 다음번엔 어플리케이션 내에서 공통적으로 사용되는 exception을 정의하고 그것을 @ControllerAdvice@ExceptionHandler를 통해 핸들링하는 방식과 함께 적절한 오류 응답을 클라이언트에게 전달하는 방법을 알아볼 예정이다.

project source: https://github.com/devAsterisk/spring-boot-rest-api-example/tree/master/sample-03

--

--