이 내용은 udemy의 SOLID Design Principles를 정리한 내용입니다.
https://www.udemy.com/course/design-patterns-in-java-concepts-hands-on-projects/
이 원칙의 일반적인 정의를 한번 살펴 보자.
클래스에서 변경되어야 할 이유는 한 가지 이상 있어서는 안된다.
Single Responsibility는 클래스는 특정 단일 기능만을 가지고 특정 관심사만 나타내는 것을 말한다.
예를 들어 원격으로 메시지를 보내는 특정 기능이 있다고 해보자.
그럼, 이 클래스에서 변경될 가능성이 있는 것은 무엇일까?
이 단일 클래스에 많은 역할을 부여했기 때문에, 변경될 이유는 많이 있을 것이다.
만일, 프로토콜이 바뀌어 클래스가 변경되어야 한다고 해보자. (HTTP -> HTTPS)
또한, 메시지 포맷이 변경되었다고 하자. (JSON -> XML)
이 때도 변경되어야 할 두 번째 이유가 생긴다.
또 다른 변경의 이유는 파라미터이다. 인증이 보안사항으로 추가되었다고 하자. 변경의 이유가 또 다시 추가된다.
이것이 Single Responsibility Principle에서 피해야 한다고 말하는 사항이다.
세 가지 책임이 있다면, 세 가지 각기 다른 클래스나 모듈을 가져야 하고, 변경될 때마다 우리 코드는 구조화된 방법으로 변경이 되어야 한다. 즉, JSON을 XML으로 메시지 포맷을 변경할 때 단지 거대한 메소드 내에서 변경하는 것을 의미하지 않는다.
이것이 Single Responsibility Principle가 의미하는 것이다.
Single Responsibility Principle는 클래스나 모듈을 디자인 할 때 클래스는 특정 관심사만 나타내고 있다는 것이고 특정 클래스에 대한 변경의 이유는 오직 하나이어야 한다는 것이다.
이제, 이 원칙의 의미를 다시 돌이켜보면 더 잘 이해가 될 것이다.
이 원칙은 클래스에 대한 변경의 이유가 하나 이상 있어서는 안되고, 우리가 클래스를 특정 기능에 집중하여 작성하면 그렇게 할 수 있다는 것을 나타낸다.
예제
Single Responsibility Principle이 위배되는 예제를 한번 살펴보고 어떻게 수정할 수 있는지 알아보자.
이 예제는 단순하다. 이름에서 알 수 있듯이 사용자 컨트롤러 클래스가 있고 MVC 웹 애플리케이션을 작성하고 있다고 하자. 실제 웹 서비스는 아니지만 스프링 같은 프레임워크에서 작성하게 될 소스이다.
public class UserController {
private Store store = new Store();
public String createUser(String userJson) throws IOException {
ObjectMapper mapper = new ObjectMapper();
User user = mapper.readValue(userJson, User.class);
if (!isValidUser(user)) {
return "ERROR";
}
store.store(user);
return "SUCCESS";
}
private boolean isValidUser(User user) {
if (!isPresent(user.getName())) {
return false;
}
user.setName(user.getName().trim());
if (!isValidAlphaNumeric(user.getName())) {
return false;
}
if (user.getEmail() == null || user.getEmail().trim().length() == 0) {
return false;
}
user.setEmail(user.getEmail().trim());
if (!isValidEmail(user.getEmail())) {
return false;
}
return true;
}
//Simply checks if value is null or empty..
private boolean isPresent(String value) {
return value != null && value.trim().length() > 0;
}
//check string for special characters
private boolean isValidAlphaNumeric(String value) {
Pattern pattern = Pattern.compile("[^A-Za-z0-9]");
Matcher matcher = pattern.matcher(value);
return !matcher.find();
}
//check string for valid email address - this is not for prod.
//Just for demo. This fails for lots of valid emails.
private boolean isValidEmail(String value) {
Pattern pattern = Pattern.compile("^[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})$");
Matcher matcher = pattern.matcher(value);
return matcher.find();
}
}
사용자 컨트롤러(UserController)는 클라이언트로 부터 요청을 받게 되는 클래스이다. 이 경우에는 메소드를 단순히 호출만 한다.
하지만, MVC 애플리케이션이나 웹 서비스에서는 컨트롤러는 요청을 받고 처리하고 클라이언트로 응답을 보내는 역할을 한다.
이 예제에서는, createUser 메소드를 가지고 있고 이 메소드는 사용자 정보를 담고 있는 JSON 문자열인 String을 인자로 받는다.
이것은 클라이언트가 생성하고자 하는 사용자인 것이다. 사용자는 응답으로 String을 리턴할 것이다.
이 경우에는 단순 텍스트 문자열을 리턴할 것이고 사용자 객체를 생성할 수 있으면 success로 리턴하고 그럴 수 없다면 error로 리턴할 것이다.
User 그 자체는 단순 클래스이며 세 가지 속성을 가지고 있고 store 클래스를 가지고 있다.
public class Store {
private static final Map<String, User> STORAGE = new HashMap<>();
public void store(User user) {
synchronized (STORAGE) {
STORAGE.put(user.getName(), user);
}
}
public User getUser(String name) {
synchronized (STORAGE) {
return STORAGE.get(name);
}
}
}
Store가 실제로는 JPA같은 Persistence를 사용할 것이지만, 이 예제에서는 HashMap으로 저장하는 Persistence 영역을 나타내고 있다.
마지막으로 Main 클래스가 있다. 이 클래스는 사용자 컨트롤러를 테스트하기 위한 것이다.
public class Main {
private static final String VALID_USER_JSON = "{\"name\": \"Randy\", \"email\": \"randy@email.com\", \"address\":\"110 Sugar lane\"}";
//Invalid USER JSON String - email format wrong
private static final String INVALID_USER_JSON = "{\"name\": \"Sam\", \"email\": \"sam@email\", \"address\":\"111 Sugar lane\"}";
public static void main(String[] args) throws IOException {
UserController controller = new UserController();
String response = controller.createUser(VALID_USER_JSON);
if (!response.equalsIgnoreCase("SUCCESS")) {
System.err.println("Failed");
}
System.out.println("Valid JSON received response: " + response);
response = controller.createUser(INVALID_USER_JSON);
if (!response.equalsIgnoreCase("ERROR")) {
System.err.println("Failed");
}
System.out.println("Invalid JSON received response: " + response);
}
}
이 컨트롤러는 요청을 받아서 응답을 보내는 역할을 한다. 컨트롤러 그 자체는 어떤 비즈니스 로직을 담고 있어서는 안 된다.
여기서 UserController는 많은 일을 하고 있다. 요청을 받아서 사용자 객체를 생성하고 있고, 객체의 유효성 검증과 객체를 HashMap으로 저장하는 일까지 하고 있다.
UserController가 변경되어야 할 여러가지 이유를 가지고 있기 때문에 Single Responsibility Principle가 위배되는 것을 알 수 있다.
만일, 유효성 검증 로직이 변경되거나 User 객체에 필드가 추가되면 UserController도 변경되어야 한다.
만일 사용자 객체를 저장하는 방식이 변경된다면, 예를 들면 실제 데이터베이스를 사용하거나 Mongo DB같은 NoSQL로 변경이 되어야 한다면, UserController도 변경되어야 한다.
여기서 우리는 UserController가 변경되어야 할 다양한 이유를 알 수 있고 Single Responsibility Principle가 위배되는 것을 알 수 있다.
그럼, 어떻게 수정할 수 있을까?
여러분의 애플리케이션에서 유사한 작업을 할 때, 첫 번째 해야할 것은 기존 코드를 테스트할 수 있는 테스트케이스를 만드는 것이다. 여기서 Main 메소드는 테스트케이스를 담당하고 있다. 실제 애플리케이션에서는 JUnit 혹은 TestNG로 테스트 케이스를 만들어야 한다.
기존 코드를 리팩토링하고 나서 부수효과로 새로운 버그가 생기지 않길 바라지 않기 때문에 이러한 내용은 매우 중요하다.
우리는 main 메소드를 실행하여 테스트 결과가 출력되고 리팩토링을 할 때마다 이 테스트케이스를 실행할 것이다.
그럼 어떻게 리팩토링을 할까? 해결책은 꽤 간단하다.
이 클래스가 가져야 할 책임과 가져셔는 안되는 책임을 우선 확인해야 한다.
예를 들면, 컨트롤러가 신경쓰지 말아야 할 유효성 검증이 있다. 이 유효성 검증은 변경이 되기 때문에 여기서 구현해서는 안된다. 이 필드는 애플리케이션이 진화할 때마다 추가되거나 삭제될 수 있다.
그래서 유효성 검증을 위한 UserValidator 클래스를 만들 것이다.
public class UserValidator {
public boolean validateUser(User user) {
if (!isPresent(user.getName())) {
return false;
}
user.setName(user.getName().trim());
if (!isValidAlphaNumeric(user.getName())) {
return false;
}
if (user.getEmail() == null || user.getEmail().trim().length() == 0) {
return false;
}
user.setEmail(user.getEmail().trim());
if (!isValidEmail(user.getEmail())) {
return false;
}
return true;
}
...
}
UserValidator는 User 객체를 검증하는데 책임이 있는 클래스이다. 이 클래스는 검증 로직에 변경이 있을 경우에만 변경이 될 것이다.
그리고 기존 UserController에서 UserValidator를 사용하도록 변경하자.
public String createUser(String userJson) throws IOException {
ObjectMapper mapper = new ObjectMapper();
User user = mapper.readValue(userJson, User.class);
UserValidator validator = new UserValidator();
boolean valid = validator.validateUser(user);
if (!valid) {
return "ERROR";
}
store.store(user);
return "SUCCESS";
}
또 다른 문제가 있다. user가 저장되는 방법이 변경될 때 UserController에서 여전히 영향을 받고 있다.
그래서 Persistence를 관리하기 위해 또 다른 클래스를 하나 만들었다.
public class UserPersistenceService {
private Store store = new Store();
public void saveUser(User user) {
store.store(user);
}
}
그리고 UserController에서 UserPersistenceService를 사용하도록 적용을 하자.
public class UserController {
private UserPersistenceService persistenceService = new UserPersistenceService();
public String createUser(String userJson) throws IOException {
...
persistenceService.saveUser(user);
return "SUCCESS";
}
}
이제 UserController를 보면 단지 요청만을 처리하고 있는가? 아니다. 여전히 JSON을 Java 객체로 변환하는 작업을 하고 있다.
ObjectMapper mapper = new ObjectMapper();
User user = mapper.readValue(userJson, User.class);
이 또한 클래스로 분리하여 작업할 수 있다. 이 작업은 여러분이 알아서 해보시기 바란다.
이제 여러분은 Single Responsibility Principle를 어떻게 수정해야 할지 알게 될 것이다.