티스토리 뷰
1. 변경을 최소화하는 개발, 관심사의 분리와 변하는 것과 변하지 않는 것의 분리
[ 요구사항의 변경 ]
시간이 지나면서 요구사항은 결국 변하게 될 것이고, 우리는 미래의 변화에 대응할 수 있는 설계를 해야 한다. 미래의 변화에 대응할 수 있는 설계를 해야하는 이유는 미래에 소요될 유지보수 시간을 줄이기 위함이다. 설계의 퀄리티에 따라 누군가는 수정사항을 반영하는데 하루가 넘는 시간이 소요될 수 있는 반면, 다른 누군가는 이러한 작업을 단 몇 시간 내에 끝낼 수도 있다. 이러한 이유로 우리는 변경이 일어날 때 필요한 작업을 최소화하고, 그 변경이 다른 곳에 영향을 미치지 않도록 해야 한다.
변경을 최소화하기 위해서는 분리와 확장을 고려한 설계를 해야 한다.
변경에 대한 요청은 한 번에 하나의 관심사항에 집중해서 일어난다. 예를 들어 "DB 접속 암호를 바꾸어주세요"라는 식으로 말이다. 하지만 문제는 그에 따른 작업이 한 곳에 집중되지 않는다는 것이다. DB 접속 암호를 바꾸기 위해 수 많은 DAO 클래스를 모두 수정해야 한다면 그 작업은 쉽지 않은 작업일 것이다. (이러한 이유로 우리가 리소스를 분리하고 있는 것이다.)
[ 관심사의 분리(Separation of Concerns) ]
그렇기 때문에 한 가지 관심에 집중되는 수정 요구에 맞게, 우리도 한 가지 관심이 한 군데에 집중되게 하면 된다. 즉, 관심이 같은 것들끼리는 모으고, 관심이 다른 것들끼리는 분리시키키는 것이다.
프로그래밍에서는 이를 관심사의 분리(Separation of Concerns)라고 부른다. 관심이 같은 것끼리는 하나의 객체로 모으고, 그렇지 않은 것은 분리시켜 영향을 주지 않도록 하는 것이다.
예를 들어 다음과 같은 게시물 관련 요청을 처리하기 위한 클래스가 있다고 하자.
public class Board {
private long id;
private String title;
private String contents;
private List<String> comments;
}
위와 같은 게시물 클래스에는 comments라는 List<String>으로 댓글을 관리하고 있다.
하지만 만약 새로운 요구사항이 답글에 답글을 달 수 있도록 하는 기능이라면 어떻게 될까?
개발자는 해당 요구사항을 반영하기 위해 새로운 댓글 테이블과 클래스를 만들고 해당 로직(게시물과 댓글)을 분리해야 할 것이고 그에 따라 파생되는 작업을 해야 할 것이다. 위와 같은 댓가를 치루는 이유는 서로 다른 관심사인 게시물과 댓글을 분리하지 않았기 때문이다.
이와 같은 문제를 방지하기 위해 우리는 관심사가 같은 것들 끼리는 모으고, 다른 것들은 분리해줌으로써 1가지 관심에 효과적으로 집중할 수 있게 해주어야 한다. 만약 우리가 게시물과 댓글을 처음부터 분리해 두었다면, 댓글에 댓글을 다는 요청은 (게시물 관련 클래스를 볼 필요 없이) 댓글 클래스에만 한정되어 작업이 진행될 것이다.
즉, 변경을 최소화할 수 있는 것이다.
[ 변하는 것과 변하지 않는 것의 분리 ]
관심사의 분리와 마찬가지로 변경을 최소화하는 또 다른 방법으로는 변하는 것과 변하지 않는 것을 분리하는 방법이 있다. 변하는 것과 변하지 않는 것을 분리함으로써 변하지 않는 것들은 유연하게 재사용할 수 있을 것이고, 변하는 것들은 추상화함으로써 수정 요청이 왔을 때 변경이 전파되는 것을 최소화할 수 있을 것이다.
예를 들어 입력으로 받은 사용자의 비밀번호를 암호화하고 새로운 회원으로 추가하는 로직에 대한 코드가 다음과 같이 있다고 하자.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public void addUser(final String email, final String pw) {
final StringBuilder sb = new StringBuilder();
for(byte b : pw.getBytes(StandardCharsets.UTF_8)) {
sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
}
final String encryptedPassword = sb.toString();
final User user = User.builder()
.email(email)
.pw(encryptedPassword).build();
userRepository.save(user);
}
}
위의 코드에는 여러가지 문제가 있는데, 우선 가장 눈에 보이는 문제는 비밀번호를 암호화하는 책임이 UserService에 있다는 것이다.
UserService라면 사용자와 관련된 비지니스 로직들이 있어야 할 것으로 예상되지만 비밀번호를 암호화하는 적합하지 않은 기능이 포함되어 있다.
그러므로 우선 비밀번호를 암호화하기 위한 로직을 별도의 클래스를 생성하고 빈으로 등록하여 책임을 새롭게 할당하도록 하자.
@Component
public class SimplePasswordEncoder {
public void encryptPassword(final String pw) {
final StringBuilder sb = new StringBuilder();
for(byte b : pw.getBytes(StandardCharsets.UTF_8)) {
sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
}
return sb.toString();
}
}
새롭게 비밀번호 암호화를 위한 클래스가 생성되었으므로 UserService에도 해당 내용을 반영해주도록 하자.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final SimplePasswordEncoder passwordEncoder;
public void addUser(final String email, final String pw) {
final String encryptedPassword = passwordEncoder.encryptPassword(pw);
final User user = User.builder()
.email(email)
.pw(encryptedPassword).build();
userRepository.save(user);
}
}
비밀번호를 암호화하는 책임은 이제 적절한 클래스를 생성하여 할당된 것 처럼 보인다.
하지만 또 다른 문제가 보이는데, 그것은 UserService 객체가 SimplePasswordEncoder에 대해 지나치게 많이 알고 있으며 강하게 결합되어 있고 추상화에 의존하지 못하고 있다는 것이다. 이러한 문제는 비밀번호를 암호화하는 방법에 변경이 필요한 경우에 문제가 생길 수 있다.
예를 들어 보안팀으로부터 해당 비밀번호 암호화 방법은 보안에 취약하므로 암호화 방법을 변경해야 하는 요구사항이 생겼다고 하자.
그러면 우리는 비밀번호를 변경하기 위한 새로운 클래스를 추가해주어야 하는데, 다음과 같이 SHA256 해시 알고리즘으로 비밀번호를 암호화하도록 수정하였다고 하자.
(물론 SHA256 해시 알고리즘을 비밀번호 암호화에 사용하는 것은 적절하지 못하다. 그 이유는 SHA256으로 해시되는 값들을 미리 적어두어 해킹하는 레인보우 테이블 공격 기법을 사용할 수 있기 때문이다. 그러므로 Bcrypto와 같은 암호화 기법을 사용해야 한다. 그러나 여기서는 높은 결합도와 이를 해결하기 위해 추상화를 적용해야 함을 설명하기 위함이므로 예시로 사용하고자 한다.)
우리는 새로운 암호화 알고리즘을 위해 다음과 같은 클래스를 추가하고 기존의 SimplePasswordEncoder를 빈에서 등록하지 않도록 수정하였다고 하자.
@Component
public class SHA256PasswordEncoder {
private final static String SHA_256 = "SHA-256";
public String encryptPassword(final String pw) {
final MessageDigest digest;
try {
digest = MessageDigest.getInstance(SHA_256);
} catch (NoSuchAlgorithmException e) {
throw new IllegalArgumentException();
}
final byte[] encodedHash = digest.digest(pw.getBytes(StandardCharsets.UTF_8));
return bytesToHex(encodedHash);
}
private String bytesToHex(final byte[] encodedHash) {
final StringBuilder hexString = new StringBuilder(2 * encodedHash.length);
for (final byte hash : encodedHash) {
final String hex = Integer.toHexString(0xff & hash);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}
새로운 암호화 알고리즘을 만들고 빈으로 등록한 것 까지는 좋았는데 문제는 암호화 알고리즘이 변경된 것이 UserService라는 비밀번호 암호화와 관련없는 객체까지 변경이 전파된다는 것이다. 우리는 UserService 객체가 주입받는 빈을 SimplePasswordEncoder에서 SHA256PasswordEncoder로 변경해주어야 한다.
이러한 문제가 발생하는 이유는 UserService가 비밀번호를 암호화하는 객체의 추상화에 의존하지 못해 결합도가 높기 때문이다.
우리는 이러한 문제를 해결하기 위해 UserService가 추상화 된 클래스 또는 인터페이스에 의존하도록 해야한다. 이러한 문제를 해결하기 위해 다음과 같이 인터페이스를 추가하고 수정하였다고 하자.
public interface PasswordEncoder {
String encryptPassword(final String pw);
}
@Component
public class SHA256PasswordEncoder implements PasswordEncoder {
private final static String SHA_256 = "SHA-256";
@Override
public String encryptPassword(final String pw) {
final MessageDigest digest;
try {
digest = MessageDigest.getInstance(SHA_256);
} catch (NoSuchAlgorithmException e) {
throw new IllegalArgumentException();
}
final byte[] encodedHash = digest.digest(pw.getBytes(StandardCharsets.UTF_8));
return bytesToHex(encodedHash);
}
private String bytesToHex(final byte[] encodedHash) {
final StringBuilder hexString = new StringBuilder(2 * encodedHash.length);
for (final byte hash : encodedHash) {
final String hex = Integer.toHexString(0xff & hash);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}
그리고 비밀번호 암호화와 관련된 빈을 주입받는 UserService를 SimplePasswordEncoder나 SHA256PasswordEncoder와 같은 구체 클래스가 아닌 PasswordEncoder 인터페이스에 의존하도록 변경해주도록 하자.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public void addUser(final String email, final String pw) {
final String encryptedPassword = passwordEncoder.encryptPassword(pw);
final User user = User.builder()
.email(email)
.pw(encryptedPassword).build();
userRepository.save(user);
}
}
이제 UserService는 비밀번호 암호화가 어떤 구체 클래스에 의해 처리되고, 어떠한 알고리즘이 사용되는지 알지 못한다. 왜냐하면 추상화에 의존하도록 하여 결합도를 낮추었기 때문이다.
추후에 SHA256 알고리즘에 대한 보안 이슈가 다시 발견되어 Bcrypt 기반으로 수정해야 하는 요구사항이 생길 수 있다.
그럴때면 우리는 해당 변경이 전파되는 범위를 최소화하여 개발의 유지보수에 많은 이점을 얻을 수 있을 것이다.
말로는 굉장히 쉬운 얘기들이지만 서로 다른 관심사들을 분리하고, 변하는 것과 변하지 않는 것을 분리하는 것은 너무도 어려운 작업이다.
또한 실제로 변하는 부분을 분리하기 위해서는 추상 클래스나 인터페이스 등을 활용한 추상화가 기반이 되어야 한다.
과거부터 변경을 최소화하기 위한 노력들이 계속 되었고, 다양한 시행 착오 끝에 SOLID와 같은 원칙들이 탄생하게 된 것이다. 그러므로 우리는 선구자들이 만들어낸 이러한 기본 원칙들을 잘 준수할 필요가 있는 것이다.
'나의 공부방' 카테고리의 다른 글
[개발서적] 클린 코더(Clean Coder) 핵심 요약 및 정리 (10) | 2021.09.04 |
---|---|
[TDD] 단위 테스트와 TDD(테스트 주도 개발) 프로그래밍 방법 소개 - (1/5) (26) | 2021.08.16 |
[디자인 패턴] 싱글톤이 안티 패턴이 될 수 있는 이유와 자바 싱글톤과 스프링 싱글톤의 차이 (6) | 2021.05.14 |
[클린코드] 좋은 코드는 어떤 코드일까? 좋은 코드에 대한 고찰 (6) | 2021.05.05 |
[OOP] 디미터의 법칙(Law of Demeter) (9) | 2021.04.24 |