■ 코드 오남용으로 인해 버그가 발생하는 방식
■ 코드를 오용하기 쉬운 흔한 방식
■ 코드를 오용하기 어렵게 만드는 기술
코드가 오용하기 쉽게 작성된다면, 조만간 오용될 가능성이 있고 소프트웨어가 올바르게 작동하지 않을 것이다.
비합리적이거나 애매한 가정에 기반해서 코드가 작성되거나 다른 개발자가 잘못된 일을 하는 것을 막지 못할 때 코드는 오용되기 쉽다.
코드를 잘못 사용할 수 있는 몇 가지 일반적인 경우는 다음과 같다.
객체가 생성된 후에 상태를 바꿀 수 없다면 이 객체는 불변immutable
이다. 불변성이 바람직한 이유를 이해하기 위해서는 그 반대인 가변 mutability
객체가 어떻게 문제를 일으킬 수 있는지 고려해야 한다.
객체를 불변으로 만드는 것이 항상 가능하지도 않고, 또 항상 적절한 것도 아니다. 필연적으로 상태 변화를 추적해야 하는 경우가 있고 이때는 가변적인 자료구조가 필요하다. 하지만 방금 설명했듯이 가변적인 객체는 코드의 복잡성을 늘리고 문제를 일으킬 수 있기 때문에, 기본적으로는 불변적인 객체를 만들되 필요한 곳에서만 가변적이 되도록 하는 것이 바람직하다.
클래스를 가변적으로 만드는 가장 일반적인 방법은 세터setter 함수를 제공하는 것이다.
class User{
private String name;
private int age;
void setName(String name){ //setter 함수를 이용해 User의 name을 수정할 수 있다.
this.name = name;
}
void setAge(int age){
this.age = age;
}
}
// User의 name을 출력하는 함수
public void printName(User user) {
doSomething(user);
System.out.println(uset.getName());
}
private void doSomething(User user){
// doSomething
user.setName("A"); // 이 메소드에서 user의 name을 강제로 A로 set해버린다.
}
클래스 내에서 변수를 정의할 때 심지어 클래스 내에서도 변수의 값이 변경되지 않도록 할 수 있다. 이 방법은 언어에 따라 다른데 공통적으로 사용하는 키워드는 const, final, readonly이다.
class User{
private final String name;
private final int age;
public User(String name, int age){
this.name = name;
this.age = age;
}
public getName() {
return this.name;
}
public getAge() {
return this.age;
}
}
이를 위한 두 가지 유용한 디자인 패턴은 다음과 같다.
클래스가 실수로 가변적으로 될 수 있는 일반적인 경우는 깊은 가변성(deep mutability)
때문이다. 이 문제는 멤버 변수 자체가 가변적인 유형이고 다른 코드가 멤버 변수에 액세스할 수 있는 경우에 발생할 수 있다.
class Users {
private final List<User> users;
public List<User> getUsers(){
return this.users;
}
}
public void doSomething(Users users){
List<User> list = users.getUsers();
list.add(new User()); // 깊은 가변성으로 인해 users의 멤버변수가 수정된다.
}
class Users {
private final List<User> users;
public List<User> getUsers(){
return new List<User>(users);
}
}
public void doSomething(Users users){
List<User> list = users.getUsers();
list.add(new User()); // getUser메소드 호출 시 새로운 List객체가 반환 되므로 users의 멤버변수의 users List는 변경이 없다.
}
class Users {
private final List<User> users;
public List<User> getUsers(){
return Collections.unmodifiableList(this.users);
}
}
public void doSomething(Users users){
List<User> list = users.getUsers();
list.add(new User()); // list가 불변객체이므로 add메소드 호출 시 exception이 발생한다.
}
정수, 문자열 및 리스트 같은 간단한 데이터 유형은 코드의 기본적인 구성 요소 중 하나다. 정수나 리스트와 같은 유형으로 표현이 ‘가능’ 하다고 해서 그것이 반드시 ‘좋은’ 방법은 아니다. 설명이 부족하고 허용하는 범위가 넓을수록 코드 오용은 쉬워진다.
예를 들어 2D 지도의 위치는 위도와 경도에 대한 두 가지 값이 모두 필요하다. 지도에서 위치를 처리하는 코드를 작성할 경우 위치를 나타내는 자료구조가 필요하다. 자료구조에는 해당 위치의 위도와 경도에 대한 값이 모두 포함되어야 한다.
지나치게 일반적인 데이터 유형
class LocationDisplay {
private final DrawableMap map;
/**
* 지도 위에 제공된 모든 좌표의 위치를 표시한다.
*
* 리스트의 리스트를 받아들이는데, 내부의 리스트는 정확히
* 두 개의 값을 가지고 있다. 첫 번째 값은 위치의 위도이고
* 두 번째 값은 경도를 나타낸다(둘 다 각도 값).
*/
void markLocationsOnMap(List<List<Double>> locations) {
for (List<Double> location in locations) {
map.markLocation(location[0], location[1]);
}
}
}
코드를 오용하기 쉽게 만드는 단점이 있는데 다음과 같다.
List<List<Double>>
유형 자체로는 아무것도 설명해주지 않는다. 개발자가 markLocationsOn지도의 2D 위치를 나타내는 경우 코드의 오용 혹은 오해의 소지를 줄이는 간단한 방법은 위도와 경도를 나타내는 전용 클래스를 정의하는 것이다.
/**
* 위도와 경도를 각도로 나타낸다.
*/
class LatLong {
private final Double latitude;
private final Double longitude;
LatLong(Double latitude, Double longitude) {
this.latitude = latitude;
this.longitude = longitude;
}
Double getLatitude() {
return latitude;
}
Double getLongitude() {
return longitude;
}
}
class LocationDisplay {
private final DrawableMap map;
/**
* 지도 위에 제공된 모든 좌표의 위치를 표시한다.
**/
void markLocationsOnMap(List<LatLong> locations) {
for (LatLong location in locations) {
map.markLocation(location.getLatitude(), location.getLongitude());
}
}
}
시간을 다룰 때 코드를 잘못 사용하고 혼동을 일으킬 여지가 굉장히 많다.
시간을 나타낼 때 일반적으로 정수나 큰 정수long integer를 사용한다. 이것으로 어느 한순간을 의미하는 시각과 시간의 양, 두 가지를 모두 나타낸다.
정수는 매우 일반적인 유형이기 때문에 시간을 나타내는 데 사용하는 경우 코드가 오용되기 쉽다
/**
* @param message 보낼 메시지
* @param deadline 데드라인이 경과하기까지 메시지가 전송
* 되지 않으면 전송은 취소된다.
* @return 메시지가 전송되면 참을, 그렇지 않으면 거짓
*/
Boolean sendMessage(String message, Duration deadline) {
...
}
더 이상 단위에 대한 혼동이 없다.
단위가 사용되어야 하는지 설명하기 위한 계약의 세부 조항이 필요하지 않으며, 실수로 잘못된 단위를 제공하는 것이 불가능하다.
Duration duration1 = Duration.ofSeconds(5);
print(duration1.toMillis()); // 출력: 5000
Duration duration2 = Duration.ofMinutes(2);
print(duration2.toMillis()); // 출력: 120000
코드에서 숫자, 문자열, 바이트 스트림과 같은 종류의 데이터를 처리하는 경우가 많다. 데이터는 종종 두 가지 형태로 제공된다.
primary data
: 코드에 제공해야 할 데이터. 코드에 이 데이터를 알려주지 않고는 코드가 처리할 방법이 없다.derived data
: 주어진 기본 데이터에 기반해서 코드가 계산할 수 있는 데이터예를 들어 은행계좌의 상태를 설명하는 데 필요한 데이터가 있을 수 있다. 여기에서 기본 데이터는 대변credit
금액과 차변debit
금액이다. 계좌 잔액은 파생 데이터인데 대변에서 차변을 뺀 금액이다.
기본 데이터는 일반적으로 프로그램에서 진실의 원천source of truth이 된다. 대변과 차변에 대한 값은 계좌의 상태를 완전히 설명하고 계좌의 상태를 추적하기 위해 저장되어야 하는 유일한 값이다.
은행계좌의 경우 계좌 잔고액은 두 가지 기본 데이터에 의해 완전히 제한된다. 대변이 5달러이고 차변이 2달러인 상태에서 잔액이 10달러라고 하는 것은 말이 안 된다.
기본 데이터와 파생 데이터를 모두 처리하는 코드를 작성할 때, 이와 같이 논리적으로 잘못된 상태가 발생할 수 있다. 논리적으로 잘못된 상태가 발생할 수 있는 코드를 작성하면 코드의 오용이 너무 쉬워진다.
class UserAccount {
private final Double credit;
private final Double debit;
private final Double balance;
UserAccount(Double credit, Double debit, Double balance) { // 대변, 차변, 잔액이 모두 생성자에 전달된다.
this.credit = credit;
this.debit = debit;
this.balance = balance;
}
Double getCredit() {
return credit;
}
Double getDebit() {
return debit;
}
Double getBalance() {
return balance;
}
}
// 잔액이 차변에서 대변을 빼는 잘못된 방법으로 계산된다.
UserAccount account = new UserAccount(credit, debit, debit - credit);
class UserAccount {
private final Double credit;
private final Double debit;
UserAccount(Double credit, Double debit) {
this.credit = credit;
this.debit = debit;
}
Double getCredit() {
return credit;
}
Double getDebit() {
return debit;
}
Double getBalance() { // 잔액은 대변과 차변으로 계산된다.
return credit - debit;
}
}
진실의 원천 sources of truth
은 코드에 제공된 데이터에만 적용되는 것이 아니라 코드에 포함된 논리에도 적용된다.
정숫값을 기록한 후에 파일로 저장하는 클래스를 보여준다. 이 코드에는 값이 파일에 저장되는 방식에 대한 두 가지 중요한 세부 정보가 있다.
class DataLogger {
private final List<Int> loggedValues;
public void saveValues(FileHandler file) {
String serializedValues = loggedValues.map(value -> value.toString(Radix.BASE_10)).join(",");
file.write(serializedValues);
}
}
DataLogger.saveValues()의 반대 과정, 즉 파일을 열고 정수로 읽어 들이는 코드도 어딘가에 있을 가능성이 크다.
이 코드는 DataLogger 클래스와 완전히 다른 파일이고 코드베이스의 다른 부분에 있을 가능성이 크지만 논리는 서로 일치해야 한다.
class DataLoader {
public List<Integer> loadValues(FileHandler file) {
return file.readAsString().split(",").map(str -> Int.parse(str, Radix.BASE_10));
}
이 시나리오에서 값이 파일에 저장되는 형식은 논리의 중요한 부분이지만, 이 형식이 무엇인지에 대해서는 진실의 원천이 두 개 존재한다.
클래스가 모두 동일한 논리를 포함하면 모든 것이 잘 작동하지만 한 클래스가 수정되고 다른 클래스가 수정되지 않으면 문제가 발생한다.
직렬화된 정수를 저장하는 형식에 대한 진실의 원천을 하나만 갖게 되면 코드가 더 견고해지고 오류의 가능성을 줄일 수 있다.
class IntListFormat {
private const String DELIMITER = ",";
private const Radix RADIX = Radix.BASE_10;
String serialize(List <Integer> values) {
return values.map(value - > value.toString(RADIX)).join(DELIMITER);
}
List <Integer> deserialize(String serialized) {
return serialized.split(DELIMITER).map(str - > Int.parse(str, RADIX));
}
}
class DataLogger {
private final List<Integer> loggedValues;
private final IntListFormat intListFormat;
public void saveValues(FileHandler file) {
file.write(intListFormat.serialize(loggedValues));
}
}
class DataLoader {
private final IntListFormat intListFormat;
List <Integer> loadValues(FileHandler file) {
return intListFormat.deserialize(file.readAsString());
}
}
이렇게 하면 앞서 DataLogger
클래스에서 사용하는 형식을 변경하지만 실수로 DataLoader
클래스는 변경하지 않는 것과 같은 위험은 제거된다.