티스토리 뷰
이번에는 이전에 작성했던 프로젝트의 코드를 실무처럼 리팩토링 해보고자 합니다.
해당 프로젝트의 원본은 이 링크에서 확인할 수 있습니다.
해당 프로젝트는 사용자의 로그인/회원가입/목록 조회를 제공하는 아주 심플한 기능밖에 없는 프로젝트임에도 불구하고, 리팩토링할 사항이 상당히 많이 있는 것 같습니다. 차근차근 하나씩 고쳐보도록 하겠습니다.
1. 기존 SpringBoot 프로젝트 실무처럼 리팩토링 하기
[ 1. Raw 타입은 사용하지 말자 ]
위의 예제에서는 다음과 같이 Controller에서 데이터를 반환할 때 ResponseEntity를 Raw 타입으로 반환하고 있다.
@RestController
@RequestMapping(value = "/error")
@Log4j2
public class ErrorController {
@GetMapping(value = "/unauthorized")
public ResponseEntity unauthorized() {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/user")
@Log4j2
public class UserController {
private final BCryptPasswordEncoder passwordEncoder;
private final UserService userService;
@PostMapping(value = "signUp")
public ResponseEntity signUp(@RequestBody User user) {
user.setRole(UserRole.ROLE_USER);
user.setPw(passwordEncoder.encode(user.getPw()));
return userService.findByEmail(user.getEmail()).isPresent()
? ResponseEntity.badRequest().build()
: ResponseEntity.ok(TokenUtils.generateJwtToken(userService.signUp(user)));
}
@GetMapping(value = "/findAll")
public ResponseEntity findAll() {
return ResponseEntity.ok(userService.findAll());
}
}
Raw 타입을 사용하면 컴파일 시점에 문제를 잡지 못하다가 런타임 시점에 ClassCastException 에러가 발생할 수 있다. 예를 들어 다음과 같은 Integer만을 갖는 List에 String이 추가되어도 오류 없이 컴파일되고 실행된다. 그러다가 해당 데이터를 꺼내거나 연산을 할 때 즉, 런타임 시점에 문제(ClassCastException 에러)가 발생하게 된다.
List list = new ArrayList<>();
list.add("문자열");
list.add(123);
int sum = 0;
for (Object num : list) {
// 런타임 시점에 ClassCastException 발생
sum += (int)num;
}
하지만 만약 Raw 타입이 아니라 타입 파라미터를 List<Integer>로 명시해준다면, 컴파일러가 리스트에 Integer만 넣어야 함을 인지하여 컴파일 시점에 오류를 잡아낼 수 있다. 왜냐하면 컴파일러는 컬렉션에서 원소를 꺼내는 모든 곳에 보이지 않는 형변환을 추가하여 절대 실패하지 않음을 보장하기 때문이다.
그렇기 때문에 위와 같은 Raw 타입의 반환을 피하고 반환할 데이터 타입을 명시해는 것이 좋다.
@RestController
@RequestMapping(value = "/error")
@Log4j2
public class ErrorController {
@GetMapping(value = "/unauthorized")
public ResponseEntity<Void> unauthorized() {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/user")
@Log4j2
public class UserController {
private final BCryptPasswordEncoder passwordEncoder;
private final UserService userService;
@PostMapping(value = "/signUp")
public ResponseEntity<String> signUp(@RequestBody User user) {
user.setRole(UserRole.ROLE_USER);
user.setPw(passwordEncoder.encode(user.getPw()));
return userService.findByEmail(user.getEmail()).isPresent()
? ResponseEntity.badRequest().build()
: ResponseEntity.ok(TokenUtils.generateJwtToken(userService.signUp(user)));
}
@GetMapping(value = "/findAll")
public ResponseEntity<List<User>> findAll() {
return ResponseEntity.ok(userService.findAll());
}
}
[ 2. Restful API는 자원과 메소드로 표현하자 ]
위의 예제에서는 Controller가 사용자 목록 조회 API(/user/findAll) 를 제공하고 있다.
@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/user")
@Log4j2
public class UserController {
... 생략
@GetMapping(value = "/findAll")
public ResponseEntity<List<User>> findAll() {
return ResponseEntity.ok(userService.findAll());
}
}
기능적인 문제는 없지만 Restful API는 자원(URI)과 메소드(GET, POST 등) 으로 해당 요청을 표현해야 한다. 하지만 사용자 목록 조회 API의 경우, URI에서도 조회라는 기능을 설명하고 있고(find...) GET이라는 메소드도 조회라는 기능을 설명하고 있다. 그렇기 때문에 해당 API의 URI를 다음과 같이 명사의 형태로 바꿔 자원과 메소드로 표현하도록 하자.
@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/users")
@Log4j2
public class UserController {
private final BCryptPasswordEncoder passwordEncoder;
private final UserService userService;
... 생략
@GetMapping
public ResponseEntity<List<User>> findAll() {
return ResponseEntity.ok(userService.findAll());
}
}
[ 3. 데이터를 주고 받을 때에는 DTO를 이용하자 ]
위의 예제에서는 데이터를 주고 받을때 엔티티를 사용하고 있다. 회원가입을 할 때는 User를 파라미터로 받고 있고, 사용자 목록을 조회할 때는 List<User>를 반환하고 있다.
@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/user")
@Log4j2
public class UserController {
private final BCryptPasswordEncoder passwordEncoder;
private final UserService userService;
@PostMapping(value = "/signUp")
public ResponseEntity<String> signUp(@RequestBody User user) {
user.setRole(UserRole.ROLE_USER);
user.setPw(passwordEncoder.encode(user.getPw()));
return userService.findByEmail(user.getEmail()).isPresent()
? ResponseEntity.badRequest().build()
: ResponseEntity.ok(TokenUtils.generateJwtToken(userService.signUp(user)));
}
@GetMapping(value = "/list")
public ResponseEntity<List<User>> findAll() {
return ResponseEntity.ok(userService.findAll());
}
}
지금은 간단한 프로젝트이기 때문에 User 객체에 @Valid와 @NotEmpty 같은 유효성 검사 코드나 패스워드 변경시 필요한 newPw와 같은 필드가 존재하지 않는다. 하지만 만약 프로젝트가 확장되어 User 객체와 같은 엔티티에 해당 코드들이 추가된다면 엔티티가 상당히 무겁고 복잡해지며 가독성이 떨어질 것이다. 그리고 만약 엔티티를 직접 반환한다면 필드명이 바뀌는 경우에 API의 스펙을 변경하게 되고, 해당 API를 사용중인 클라이언트에게 문제를 발생시킬 수 있다.
또한 지금은 사용자 목록을 반환하는 경우에는 List를 그대로 반환하고 있다. DTO를 사용하지 않고 있으므로, 만약 해당 API에 total count를 추가해달라는 요구사항이 생기는 경우에 상당히 유연성이 떨어진다. 또한 DTO의 이름을 SignUpDTO와 같이 작명한다면, 해당 요청을 통해 어떠한 파라미터를 받는지 직관적으로 짐작할 수 있다.
그렇기 때문에 우리는 다음과 같은 이유로 Entity와 DTO를 분리하여 사용해야 한다.
- 불필요한 코드 및 로직을 엔티티로부터 분리할 수 있다.
- 엔티티가 변경되어도 API 스펙이 변하지 않는다.
- 요청으로 넘어오는 파라미터를 직관적으로 확인가능하며, API의 유연성을 확보할 수 있다.
그 외에 함수의 파라미터가 너무 긴 경우에도 가독성이 떨어지므로, DTO를 사용하는 것도 좋은 선택지이다.
위의 코드를 DTO로 변경하기 위해 우선 다음과 같은 회원가입 DTO와 사용자 목록 반환 DTO를 생성하도록 하자.
@Getter
public class SignUpDTO {
private String email;
private String pw;
}
@Getter
@Builder
public class UserListResponseDTO {
private final List<User> userList;
}
또한 User 객체를 이제 직접 생성해주어야 하므로 lombok을 통해 builder 패턴을 추가해주도록 하자. 이에 맞게 User 클래스를 수정하면 다음과 같다.
@Entity
@Table(name = "USER")
@Getter
@Builder
@NoArgsConstructor(force = true)
public class User extends Common implements Serializable {
@Column(nullable = false, unique = true, length = 50)
private final String email;
@Setter
@Column(nullable = false)
private String pw;
@Setter
@Column(nullable = false, length = 50)
@Enumerated(EnumType.STRING)
private UserRole role;
}
그리고 UserController에 해당 DTO를 반영하여 다음과 같이 수정하도록 하자.
@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/user")
@Log4j2
public class UserController {
private final BCryptPasswordEncoder passwordEncoder;
private final UserService userService;
@PostMapping(value = "/signUp")
public ResponseEntity<String> signUp(@RequestBody SignUpDTO signUpDTO) {
User user = User.builder()
.email(signUpDTO.getEmail())
.pw(passwordEncoder.encode(signUpDTO.getPw()))
.role(UserRole.ROLE_USER)
.build();
return userService.findByEmail(user.getEmail()).isPresent()
? ResponseEntity.badRequest().build()
: ResponseEntity.ok(TokenUtils.generateJwtToken(userService.signUp(user)));
}
@GetMapping(value = "/list")
public ResponseEntity<UserListResponseDTO> findAll() {
UserListResponseDTO userListResponseDTO = UserListResponseDTO.builder()
.userList(userService.findAll()).build();
return ResponseEntity.ok(userListResponseDTO);
}
}
[ 4. final과 함께 생성자 주입을 사용하라 ]
오늘날 이제는 더 이상 @Autowired를 활용한 필드 주입이 아닌 final + 생성자 주입을 사용하며, lombok은 이를 상당히 간단하게 구현할 수 있도록 해주었다.
(이와 관련된 내용은 이 글에서 자세히 다루었다.)
하지만 작성된 코드를 보면 다음과 같이 final을 붙일 수 있음에도 붙이지 않는 경우가 있다.
@RequiredArgsConstructor
@Service
public class UserServiceImpl implements UserService {
@NonNull
private UserRepository userRepository;
... 생략
}
위의 코드 처럼 객체가 변하지 않는 경우에는 모두 final과 @RequiredArgsConstructor를 이용하도록 개선하자.
@RequiredArgsConstructor
@Service
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
... 생략
}
[ 5. Controller는 최대한 가볍게 만들어라 ]
Spring에서 Controller는 클라이언트의 요청이 들어오고 나가는 곳이다. 요청을 처리하기 위한 비지니스 로직은 상당히 복잡할 수 있다. 하지만 이를 주고 받는 컨트롤러는 어떠한 데이터를 주고 받는지만을 명확하게 작성하는 것이 좋다. 그래야 다른 사람이 해당 API를 봤을 때 이해하기 쉬울 것이다.
그렇기 때문에 지금처럼 SignUpDTO를 통해 User를 생성하고, 암호화를 하는 로직을 서비스 레이어로 넘기도록 하자. 지금은 userService의 signUp이 User를 파라미터로 받고 있지만, 이제는 SignUpDTO를 파라미터로 받아야 한다. 또한 암호화를 위한 PasswordEncoder 역시 UserService로 이동시켜 주어야 한다.
@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/user")
@Log4j2
public class UserController {
private final UserService userService;
@PostMapping(value = "/signUp")
public ResponseEntity<String> signUp(@RequestBody SignUpDTO signUpDTO) {
return userService.findByEmail(signUpDTO.getEmail()).isPresent()
? ResponseEntity.badRequest().build()
: ResponseEntity.ok(TokenUtils.generateJwtToken(userService.signUp(signUpDTO)));
}
... 생략
}
위와 같이 컨트롤러를 단순화하여 우리는 해당 API를 명확히 이해할 수 있게 되었다. 그리고 비밀번호 암호와와 같은 로직은 서비스단으로 이동하게 되었다.
@RequiredArgsConstructor
@Service
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
@Override
public User signUp(SignUpDTO signUpDTO) {
User user = User.builder()
.email(signUpDTO.getEmail())
.pw(passwordEncoder.encode(signUpDTO.getPw()))
.role(UserRole.ROLE_USER)
.build();
return userRepository.save(user);
}
... 생략
}
[ 6. 불변 객체를 사용하라 ]
많은 개발자가 협업하고, 개발 팀원들이 계속해서 변화하는 오늘날에는 불변 객체를 사용하는 것이 특히 중요하다.
(이와 관련된 내용은 이 글에서 자세히 다루었다.)
그렇기 때문에 변경가능성이 없는 객체라면 final로 선언하여 불변성을 확보하도록 하자. 내 코드를 읽거나 수정하는 다른 누군가는 final로 선언된 객체를 안전하게 사용할 수 있을 것이다. 또한 자연스럽게 우리는 부수효과가 없는 순수 함수로 개발을 하게 될 것이다.
@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/user")
@Log4j2
public class UserController {
private final UserService userService;
@PostMapping(value = "/signUp")
public ResponseEntity<String> signUp(@RequestBody final SignUpDTO signUpDTO) {
return userService.findByEmail(signUpDTO.getEmail()).isPresent()
? ResponseEntity.badRequest().build()
: ResponseEntity.ok(TokenUtils.generateJwtToken(userService.signUp(signUpDTO)));
}
@GetMapping(value = "/list")
public ResponseEntity<UserListResponseDTO> findAll() {
final UserListResponseDTO userListResponseDTO = UserListResponseDTO.builder()
.userList(userService.findAll()).build();
return ResponseEntity.ok(userListResponseDTO);
}
}
위의 코드를 보면 이제 매개변수, 지역변수, 클래스 변수 등 변경가능성이 없는 모든 변수들에 final이 붙어있다. UserController 외에 다른 클래스들에도 마찬가지로 final을 적극 활용해주도록 하자.
[ 7. 유틸성 또는 상수형 클래스는 내부 생성자를 통해 객체 생성을 제한하자 ]
일반적인 유틸리티성 클래스라면 다음과 같이 static 메소드를 제공하기 때문에 객체를 생성할 필요가 없다.
@Log4j2
public final class TokenUtils {
private static final String secretKey = "ThisIsA_SecretKeyForJwtExample";
public static String generateJwtToken(User user) {
JwtBuilder builder = Jwts.builder()
.setSubject(user.getEmail())
.setHeader(createHeader())
.setClaims(createClaims(user))
.setExpiration(createExpireDateForOneYear())
.signWith(SignatureAlgorithm.HS256, createSigningKey());
return builder.compact();
}
... 생략
}
그런데 유틸성 클래스를 객체를 생성하도록 만든 것은 불필요하게 코드를 열어두는 것이므로, 내부 생성자를 통해 이를 제한할 필요가 있다. 롬복을 사용중이라면 NoArgsConstructor를 이용하고, 그렇지 않다면 직접 내부 생성자를 추가하도록 하자. 이를 통해 누군가 객체를 생성하려고 할 때 컴파일 오류를 발생시킬 수 있다.
@Log4j2
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class TokenUtils {
private static final String secretKey = "ThisIsA_SecretKeyForJwtExample";
public static String generateJwtToken(User user) {
JwtBuilder builder = Jwts.builder()
.setSubject(user.getEmail())
.setHeader(createHeader())
.setClaims(createClaims(user))
.setExpiration(createExpireDateForOneYear())
.signWith(SignatureAlgorithm.HS256, createSigningKey());
return builder.compact();
}
... 생략
}
위와 같이 진행하여 코드를 개선한 후 master 브랜치로 merge를 하였다.
추가로 위의 내용에서는 해당 사항이 없어 다루지 않았지만 커맨드와 쿼리의 분리나 정적 팩토리 메소드의 사용, 비지니스 로직과 무관한 PK의 사용, 디미터의 법칙 등 더 참고할 내용이 분명히 있을 것이다. 또한 위의 내용에서 암호화한 부분을 함수로 빼내는 등 더 개선사항이 있을 것이다. 이렇게 더 좋은 코드를 작성하고자 한다면 클린 코드, 이펙티브 자바와 같은 책을 읽기를 권한다. 또한 누군가는 위에 적힌 설명에 공감하지 못할 수도 있다. 취향의 영역이 분명히 존재할 수 있으니, 필요한 부분만 얻어가는 유연한 자세로 받아드리도록 하자!
위의 코드에서도 분명 개선할 부분이 있을 것입니다. 추가적인 인사이트가 있으신 분들은 관련 내용을 댓글로 남겨주시거나 PR을 주시는 등의 조치를 주시면 반영하도록 하겠습니다.
감사합니다:)
'Spring' 카테고리의 다른 글
[Spring] @Autowired 빈 탐색 전략과 @Qualifier와 @Primary (8) | 2021.04.25 |
---|---|
[Spring] JUnit과 Mockito 기반의 Spring 단위 테스트 코드 작성법 (3/3) (41) | 2021.04.20 |
[Spring] 빈(Bean)의 생성과 소멸에 대한 관리(@PostConstruct초기화와 @PreDestroy소멸자) (0) | 2021.02.02 |
[Spring] 다양한 의존성 주입 방법과 생성자 주입을 사용해야 하는 이유 - (2/2) (38) | 2021.02.02 |
[SpringBoot] SpringBoot와 Retrofit2 연동 및 Retrofit2 사용법 (0) | 2021.02.01 |