개발자에게 삽질은 숙명과도 같다. 새로운 기술을 쓰다가 막혔을 때, 개발했는데 예상대로 동작하지 않을 때, 우리는 끊임없이 삽질을 하게 된다. 이번 편은 삽질의 시간을 줄일 수 있는 팁을 얘기해 보려 한다.
삽질이 성장과 무슨 상관이냐고 할 수 있겠지만 생각보다 상관이 있다. 일단 삽질 시간을 줄이게 되면 절약된 시간만큼 내가 더 빠르게 성장할 수 있다. 또한 삽질 시간을 단축시키기 위해 차근차근 문제에 접근하다 보면 점점 논리적으로 사고하는 능력이 자라나서 문제 해결을 잘하는 개발자로 성장할 수 있다. 삽질을 통해 배우는 것이 있기 때문에 성장의 자양분이 되기도 한다.
삽질의 원인은 다양하다. 개인 차가 있겠지만 내 경우엔 나도 모르게 잘못된 가정을 하고 문제를 해결하려고 했기 때문에 삽질했던 경우가 많았다. 오늘 이 부분을 자세히 다뤄보려 한다. 어떻게 나도 모르게 잘못된 가정을 하게 되는지 몇 가지 예를 통해 실제로 살펴보자. 레츠게릿!
사칙연산을 할 수 있는 계산기를 python으로 간단하게 구현했다. 제대로 동작하는지 확인하기 위해 테스트용 코드를 짜고 출력했더니 아래와 같은 메시지가 나왔다.
1 + 2 는 0.5 입니다.
1 더하기 2는 3인데 0.5라고 한다. 어딘가 잘못된 것이다. 이 메시지만 보고 여러분이라면 어디가 잘못일 것이라고 짐작하는가? 아래로 바로 내려가지 말고 10초만 생각해 보자. 여러분은 무엇을 점검하겠는가?
힌트를 드리기 위해 계산기를 구현한 코드를 일부 공유하겠다. 중략된 부분을 포함하여 어디에 문제가 있을 것이라고 생각되는가? 이번에는 30초 정도 더 고민해 보자. (python3로 구현)
def print_operate(operator, o1, o2):
result = operate(operator, o1, o2)
... 중략 # 입력과 결과를 출력함
def operate(operator, o1, o2):
if operator == '+':
return add(o1, o2)
if operator == '-':
return sub(o1, o2)
if operator == '*':
return mul(o1, o2)
if operator == '/':
return div(o1, o2)
raise AssertionError(f"invalid operator, {operator}")
def add(o1, o2):
... # 중략
def sub(o1, o2):
... # 중략
def mul(o1, o2):
... # 중략
def div(o1, o2):
... # 중략
만약 add 함수에 문제가 있을 것이라고 생각한다면 add 함수를 공개하겠다. 보시는 것처럼 add 함수는 전혀 문제가 없다. 그럼 어디에 문제가 있다고 생각하는가? add 함수에 문제가 없다는 것을 알고 혼란스러워진 분이 있다면 1분 정도 생각해 보고 다음으로 넘어가자.
def add(o1, o2):
return o1 + o2
여전히 어디가 문제인지 모르겠는 분이 계시다면 그분은 본인도 모르게 가정하고 있을지 모른다. 출력하는 코도에는 문제가 없다는 가정 말이다. 정답을 공개하겠다. print_operate 함수는 아래와 같다. 어디가 문제인지 보이십니까??
def print_operate(operator, o1, o2):
result = operate(operator, o1, o2)
print(f"{o1} + {o2} 는 {result} 입니다.")
+가 하드코딩 되어 있다. 그러니 사칙연산 중 무엇을 하든 출력은 +로 고정되어 잘못된 메시지를 주는 것이다. 제대로 수정한 버전은 아래와 같다.
def print_operate(operator, o1, o2):
result = operate(operator, o1, o2)
print(f"{o1} {operator} {o2} 는 {result} 입니다.")
이제는 아래처럼 호출해도 정상적으로 출력된다
>>> print_operate('/', 1, 2)
1 / 2 는 0.5 입니다.
위 예제는 독자가 본인도 모르게 잘못된 가정을 하도록 유도하기 위해 고도로 치밀하게 계획된 예제였다. 위의 예제는 코드 양이 적기 때문에 코드 전체를 드렸다면 금방 발견했을 문제였지만, 만약 코드 양이 방대하다면? 혹은 기술적으로 잘못 이해하거나 가정하고 있어서 삽질을 하고 있는 것이라면? 삽질의 시간은 매우 늘어나게 된다.
아무리 해도 막히거나 이해가 잘 안될 때는 내가 은연중에 잘못된 가정을 하고 있는 것은 아닌지 고민해 봐야 한다.
사칙연산 계산기를 이번엔 코틀린으로 작성해 보자. 리팩토링을 통해 python으로 작성할 때 보다 간단명료해졌다.
fun calculator(op: Char, o1: Int, o2: Int): Number {
return when (op) {
'+' -> o1 + o2
'-' -> o1 - o2
'*' -> o1 * o2
'/' -> o1 / o2
else -> throw IllegalArgumentException("Wrong operator: " + op)
}
}
calculator 함수를 main에서 호출하여 실행해 보자. 위의 실수를 반복하지 않기 위해 연산자가 잘 출력되도록 작성했다. 이제 어떤 응답이 나올 것이라고 기대하는가?
fun main() {
val op = '/'
val o1 = 1
val o2 = 2
println("$o1 $op $o2 는 ${calculator(op, o1, o2)} 입니다.")
}
실행했을 때 출력된 메시지는 아래와 같다. 헉!! 이번엔 또 왜?!! 0.5가 아니고 0이란 말인가?? 멘붕이다 ㄷㄷㄷ
1 / 2 는 0 입니다.
눈치 빠른 분들은, 혹은 이미 이유를 알고 계신 분들은 아시겠지만, 그런 분들이 아니라면 어디서 나도 모르게 잘못된 가정을 하고 있는 것인지 1분 정도 생각해 보자.
짐작이 되셨을지 모르겠다. 여기서 우리가 우리도 모르게 한 가정은 '1 / 2는 당연히 0.5니까 코틀린에서도 0.5로 계산해 줄 것이라는 가정'이다. python3에서는 정수끼리 나누어도 실수(real number)로 결과를 계산한다. 만약 정수끼리 나누어 몫만 얻고 싶다면 python3에서는 //(slash 두 개)를 써야 한다. 하지만 코틀린에서는 정수끼리 나누면(/) 그 결과는 몫만 반환한다. 그래서 1 / 2 = 0이 되는 것이다.
이 부분을 몰랐다면 도대체 왜 이런 결과가 나오는지 멘붕에 빠질 수 있다. 처음부터 모든 것을 알고 개발하면 좋겠지만 현실은 그렇지 않지 않은가? 그렇다면 난관에 부딪칠 때 우리는 생각해 보아야 한다.
'내가 나도 모르게 잘못된 가정을 하고 있는 것은 아닐까?'
그래서 '호옥시..! 코틀린에서는 나눗셈(/) 이 다르게 동작 하나?' 이런 식으로 가정을 바꿔 생각해 보고 검색해 본다면, 몰랐던 사실을 알게 되고 삽질 시간 또한 줄일 수 있을 것이다.
김개발은 서비스 응답 속도 향상을 위해 기존 백엔드에 캐시(cache) 용도로 사용할 Redis를 추가하기로 결정했다. AWS에서 서버 한 대를 받아서 Redis를 설치한 뒤 애플리케이션 서버단에서 Redis를 바라보게 하고 잘 동작하는지 테스트를 하는데,, 이상하게도 Redis로 연결이 되지 않고 Connection Refused라는 에러 메시지만 뱉는 것이었다. 설치가 잘못됐나 싶어서 Redis가 설치된 서버(이하 Redis 서버)에 접속하여 Redis가 잘 떠 있는지 확인했지만 Redis는 잘 떠 있고 실제로 몇몇 커맨드로 테스트했을 때 Redis가 정상적으로 동작하는 것도 확인했다. 그런데 왜 애플리케이션 서버에서 Redis로 접근하는 것은 안되는 것일까?'
김개발은 네트워크 상의 이슈일까 싶었다. 그래서 애플리케이션 서버에 접속에서 Redis 서버의 ip 주소로 ping을 했더니 응답이 잘 온다. 그럼 Redis 서버로의 연결은 문제없다는 것인데 도대체 어디가 문제란 말인가? .... 김개발은 개발둥절하기 시작했다.
혹시 ping을 모르시는 분을 위해 간단하게 설명드리면, ping은 네트워크 상의 ip 주소나 도메인 이름을 입력하면 해당 호스트(여기서 호스트란 인터넷을 이용하기 위해 연결된 컴퓨터를 통칭하는 용어이다. 그러므로 서버도 호스트의 한 종류다.)로 도달 가능한지를 알려주는 유틸리티다. 예를 들어, 터미널에서 google.com으로 ping을 날리면 아래와 같이 나온다. 약 42밀리초 내외로 응답을 받고 있음을 알 수 있다.
➜ ~ ping google.com
PING google.com (142.250.207.46): 56 data bytes
64 bytes from 142.250.207.46: icmp_seq=0 ttl=116 time=41.747 ms
64 bytes from 142.250.207.46: icmp_seq=1 ttl=116 time=42.806 ms
64 bytes from 142.250.207.46: icmp_seq=2 ttl=116 time=42.567 ms
김개발은 애플리케이션 서버에 접속해서 Redis 서버로 ping을 날렸고 위와 비슷한 응답을 받았다. 그러므로 애플리케이션 서버에서 Redis 서버로는 막힘없이 연결된다는 의미인데 왜 애플리케이션에서는 Connection Refused라는 에러를 뱉고 있는 것일까?
김개발은 어디에서 막힌 것일까? 무엇이 문제였을까? 1분만 더 고민해 보자.
59초
58초
57초..
..
ㅈㅅ
1분이 지났다. 눈치 빠른 분들은 이번 챕터가 나도 몰래 내가 저지른 사랑이 아니라 잘못된 가정을 찾는 내용이므로 김개발이 무엇을 잘못 가정하고 있는지를 찾으려고 하셨을 것이다.
맞다. 김개발은 무언가를 본인도 모르게 잘못 가정하고 있다. 김개발은 애플리케이션 서버에 접속해서 Redis 서버로 ping을 날렸을 때 응답이 잘 왔기 때문에 연결에는 문제가 없다고 생각했다. 하지만 'Connection Refused'라는 에러는 연결이 거부됐다는 메시지 아닌가? 김개발의 생각과 에러 메시지가 강하게 충돌했다. 사람의 생각과 에러 메시지가 싸우면 사람이 틀린 것이다. 이 세계는 주로 그렇다..
김개발은 무엇을 본인도 모르게 잘못 가정하고 있었던 것일까? 김개발은 ping의 응답이 정상적으로 오는 것을 보고 애플리케이션 서버에서 Redis 서버로의 연결은 문제없다고 생각했다. 그럼 돼야 하는 것 아닌가?
잠깐만..
...
우리가 연결하려는 것은 Redis가 설치된 서버인가 아니면 Redis 인스턴스인가?
김개발은 자기 자신도 모르게 ping이 서버에 떠 있는 인스턴스까지의 도달 여부를 알려 준다고 잘못 가정했다. ping은 서버까지의 도달 여부만 알려주는데 말이다.
이번 삽질은 김개발이 OSI 7계층에 대한 이해와 그에 기반해서 ping이 어떻게 동작하는지 정확히 몰랐기 때문에 발생한 문제였다. ping은 OSI 7계층에서 3번째 계층(layer)인 Network layer에서 동작하는 유틸리티다. Network layer는 인터넷상에서 호스트와 호스트의 연결을 담당하는 계층이다. 그러므로 호스트에 떠 있는 인스턴스까지의 연결을 담당하진 않는다. 호스트에 떠 있는 인스턴스까지의 연결을 담당하는 계층은 4번째 계층인 Transport layer이다. 즉, 포트(port) 번호를 통해 호스트의 인스턴스와 통신하는 역할을 바로 이 Transport layer가 담당하는 것이다. 그러므로 3번째 계층에서 동작하는 ping만으로는 Redis 인스턴스까지의 도달 여부를 파악할 수 없으며 Redis가 설치된 서버에 도달 가능한지만 파악할 수 있다. (사실 ping을 사용할 때 포트 번호를 입력하지 않았다는 것에 힌트가 있었다.)
ping에 대해 검색해 본 후 본인의 실수를 깨달은 김개발은 Redis를 설치할 때 기본 설정을 그대로 유지했기 때문에 Redis의 기본 포트 번호인 6379로 연결이 되는지 테스트를 해야겠다고 생각했다. 좀 더 검색을 해보니, 인스턴스 도달 여부를 확인하기 위해서는 telnet이라는 유틸리티를 사용하면 손쉽게 알 수 있다는 것을 알게 됐다.. 애플리케이션 서버에 접속해서 telnet으로 Redis 서버의 ip 주소와 Redis 인스턴스의 포트 번호를 입력한 후 테스트했더니, 역시나!!! 연결이 안 되는 것이었다.
이를 통해 김개발은 Redis 서버까지는 연결이 되지만 포트 번호에서 막힌다는 것을 알게 됐다. '누가 포트 번호를 막은 걸까?' 이런 생각이 드는 순간!! 아...!! AWS 보안 그룹에서 Redis 포트를 열어줘야 했는데 오랜만에 서버 추가를 하다 보니 그걸 까맣게 잊고 있었다는 사실을 알게 됐다.
이번 일을 통해 김개발은 한 단계 더 성장할 수 있어서 뿌듯했다. 네트워크 세계관에서 OSI 7계층과 호스트와 포트를 좀 더 피부로 와닿게 인식한 계기가 됐고 ping과 telnet을 더 정확히 이해한 계기가 됐기 때문이다. 게다가 지식의 빈틈 때문에 삽질하고 있을 때 나도 모르게 내가 저지른 잘못된 가정이 무엇인지 찾아가다 보면 그 빈틈을 메꿀 수 있다는 멋지고 소중한 경험을 얻었기 때문이다.
여러분도 그렇쥬?? ;)
김개발은 축구 선수의 정보를 저장할 수 있는 Java 클래스를 아래와 같이 정의했다. 등번호, 선수 이름, 선수가 소속된 팀 이름을 정보로 가진다.
class Player {
public final int backNumber;
public final String name;
public final String teamName;
public Player(int backNumber, String name, String teamName) {
this.backNumber = backNumber;
this.name = name;
this.teamName = teamName;
}
}
그리고 경기에서 골을 기록한 선수의 정보를 List<Player>에 저장하기로 했다. 그러다 보니 여러 경기들에서 골을 넣은 선수들의 유니크한 명단을 추리고 싶어서 이를 구하는 메서드를 아래와 같이 구현했다. 파라미터로 List의 List를 받은 이유는 경기에서 골 넣은 선수들의 명단을 여러 경기를 모아서 한 번에 처리하기 위함이며, 내부 구현을 살펴보면 HashSet을 활용해서 골을 기록한 유니크한 선수 명단을 뽑을 수 있도록 했다. Set은 중복을 허용하지 않기 때문에 이런 상황에서 아주 적절한 자료 구조라 생각했다.
public Set<Player> toUniquePlayers(List<List<Player>> playersList) {
Set<Player> uniquePlayers = new HashSet<>();
for (List<Player> players : playersList) {
uniquePlayers.addAll(players);
}
return uniquePlayers;
}
김개발은 본인이 구현한 toUniquePlayers가 잘 동작하는지 확인하기 위해 테스트 케이스를 아래와 같이 작성했다. 토트넘을 좋아하는 김개발은 챔피언스리그 준결승전에서 토트넘과 리버풀이 만났다고 가정하고 테스트 케이스를 짰다. 1차전에는 토트넘이 1:2로 패했지만 2차전에는 토트넘이 3:1로 승리하면서 총합 4:3으로 토트넘이 결승에 진출하는 시나리오였다. 자, 두 번의 경기에서 한 번이라도 골을 기록한 선수들 명단을 추려보자. toUniquePlayers를 실행해서 반환된 Set에는 예상대로라면 네 명의 선수가 있어야 한다.
@Test
public void toUniquePlayersTest() {
List<Player> goalPlayerOf1stGame = List.of(
new Player(11, "Mohamed Salah", "Liverpool FC"),
new Player(10, "Sadio Mané", "Liverpool FC"),
new Player(7, "Heung-Min Son", "Tottenham Hotspur")
);
List<Player> goalPlayerOf2ndGame = List.of(
new Player(7, "Heung-Min Son", "Tottenham Hotspur"),
new Player(10, "Harry Kane", "Tottenham Hotspur"),
new Player(11, "Mohamed Salah", "Liverpool FC"),
new Player(7, "Heung-Min Son", "Tottenham Hotspur")
);
Set<Player> uniquePlayers = toUniquePlayers(List.of(goalPlayerOf1stGame, goalPlayerOf2ndGame));
Assertions.assertEquals(4, uniquePlayers.size());
}
헉! 그런데 테스트 케이스가 실패하는 것이 아닌가?! 기대했던 것은 네 명인데, 실제로 uniquePlayers에는 일곱 명의 선수가 있다는 메시지와 함께 말이다.. 이게 어떻게 된 일일까?
expected: <4> but was: <7>
Expected :4
Actual :7
...
앞서 김개발은 ping으로 삽질했던 경험을 떠올리며, 본인이 잘못 가정하고 있는 것은 없는지 생각해 보기 시작했다. (여러분도 김개발이 돼서 1분 정도 생각해 보시면 좋겠다.)
김개발은 예상과는 다르게 일곱 명의 선수가 uniquePlayers에 있다는 것에 주목했다. 일곱이라는 숫자는 중복을 포함했을 때 골을 넣은 선수 수다. 이것이 의미하는 바는 Set이 예상과는 다르게 중복을 허용했다는 뜻이다.
김개발은 혼란스러웠다. Set은 중복을 허용하지 않는 자료구조인데 왜 중복을 허용한 것일까? Set은 중복을 허용하는 않는다는 생각에 잘못된 가정이 있는 것일까? 하지만 김개발은 자료구조 수업에서 Set은 중복을 허용하지 않는다는 것을 정말로 확실히 배웠었다. 혹시나 싶어서 구글링을 해봐도 Set은 중복을 허용하지 않는다고 나온다.
김개발은 다시 차근차근 생각해 봤다. '그렇다면 나도 모르게 가정하고 있는 또 다른 것이 있는 걸까?' 쉽사리 떠오르지 않았다. 이것은 마치 모래사장에서 진주를 찾아야 하는 기분이었다. 김개발은 ping 때의 경험이 있었기 때문에, Set이 중복을 허용했다는 본인의 생각이 틀린 것은 아닌지 생각하기 시작했다. 김개발이 'Set이 중복을 허용했다'라고 생각했던 이유는, 가령 손흥민을 의미하는 객체 세 개를 Set에 넣었기 때문에 Set에는 손흥민 객체 하나만 있어야 한다고 생각했었는데 실제로는 손흥민을 나타내는 객체 세 개가 들어있었기 때문이었다.
'아니 왜..? 등번호, 선수 이름, 팀 이름이 모두 같으면 중복 아냐?' 이 질문을 던지고 정확히 5초 뒤 김개발의 머릿속에서 느낌표가 반짝했다. 김개발은 Player의 멤버 변수인 등번호, 선수 이름, 팀 이름이 모두 같으면 HashSet은 알아서 중복을 허용하지 않고 하나만 가지고 있을 것이라고 본인도 모르게 가정하고 있었던 것이었다.
'혹시 알아서 해주는 것이 아니라 추가적인 작업을 해줘야 하나?' 여기까지 생각이 이르자 김개발은 빠르게 구글링을 시작했다. 'hashset unique object'로 검색해서 알게 된 사실은 김개발이 원하는 상태로 동작하게 하기 위해서는 Player 클래스에 equals과 hashCode라는 메서드를 적절히 override 해줘야 한다는 것이었다. 왜 이 두 메서드를 반드시 override 해줘야 하는지는, HashSet의 내부 구현이 HashMap을 사용하고 있고 HashMap(자료구조의 hash table과 거의 유사함)의 동작 방식이 hashCode와 equals를 반드시 필요로 하기 때문임을 알게 되면서 이해가 됐다. 정리하면 이랬다. HashSet은 (그리고 HashMap은) hashCode와 equals 메서드를 통해 중복 여부를 판단한다.
몇몇 샘플 코드를 참고한 뒤 class Player에 hashCode와 equals를 아래처럼 적절하게 override 해주었다.
class Player {
public final int backNumber;
public final String name;
public final String teamName;
public Player(int backNumber, String name, String teamName) {
this.backNumber = backNumber;
this.name = name;
this.teamName = teamName;
}
@Override
public int hashCode() {
return Objects.hash(backNumber, name, teamName);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Player player = (Player) o;
return backNumber == player.backNumber && Objects.equals(name, player.name) && Objects.equals(teamName, player.teamName);
}
}
다시 테스트 케이스를 돌렸다. 왠지 느낌이 좋다. 이젠 될 것 같다! 됐다!! 성공이다!!!!!!
자리를 박차고 일어나 됐다!를 외치며 가벼운 마음으로 창밖을 바라보던 김개발은 그렇다면 왜 처음엔 일곱 명의 선수가 들어있었는지 궁금해졌다. Set은 중복을 허용하지 않는 것이 확실하다. 그렇다면 override 하기 전의 hashCode와 equals 메서드는 어떻게 동작하고 있었단 말인가? 살짝 귀찮았지만 개발자로 훌륭하게 성장하기 위해서는 집요함이 필요하다고 배웠기 때문에 다시 구글링을 시작했다. 그리고 알게 된 사실은 hashCode의 기본 동작은 객체의 메모리 주소를 사용하여 hash 값을 만든다는 것이었다. equals의 기본 동작은 두 객체의 주소를 비교한다는 것이었다. 그래서 그랬던 것이다. hashCode와 equals를 override 하기 전에는 객체의 메모리 주소로 비교를 했기 때문에 모든 객체는 유니크한 메모리 주소를 가지므로 중복이 발생하지 않아 일곱 개의 객체가 모두 들어 있었던 것이었다.
네 가지의 사례를 통해 나도 모르게 내가 한 가정이 어떻게 삽질에 영향을 줄 수 있는지 살펴보았다. 모든 지식을 처음부터 잘 알고 시작하면 좋겠지만, 실제로 그렇게 하기란 쉽지 않다. 그런 의미에서 삽질은 일반적인 지식 습득 과정의 역주행(?)이라고 볼 수 있다. 어딘가 지식의 빈틈이 있으면 삽질을 하게 되고, 삽질 끝에 몰랐거나 잘못 알던 지식을 제대로 알게 되니 말이다.
삽질의 과정은 고통스럽기에 그 시간을 줄일 수 있다면 최대한 줄일 수 있는 것이 좋다. 그리고 그 방법 중에 하나로 나도 모르게 내가 저지른 잘못된 가정을 찾아보자는 내용을 예를 통해 자세히 살펴봤다.
내가 이런 접근을 하게 된 계기는 스스로 복기하는 습관이 있었기 때문이었다. 몇 시간 길게는 며칠을 삽질한 뒤 원인을 찾았을 때 어떻게 하면 조금 더 빨리 이 삽질의 시간을 줄일 수 있었을까 복기하곤 했다. 시간이 아까웠기 때문이다. 그렇게 해서 발견한 것은, 삽질이 길었던 이유의 상당 부분은 나도 모르게 내가 잘못된 가정을 하고 있었기 때문이었다.
설명에 사용된 예들은 비교적 간단했지만, 현업에서 삽질을 하다 보면 훨씬 난해하고 복잡한 상황에서 삽질을 하게 된다. 그렇더라도 원칙은 동일하다. 분명 어딘가 내가 저지른 잘못된 가정이 있을 것이다. 그것을 잘 찾는 훈련을 한다면 어느 날 삽질의 시간이 많이 줄어든 나를 발견하게 된다. 그리고 절약된 시간만큼 성장의 속도는 빨라지게 된다.
오래돼서 정확히는 기억나지 않지만 초등학생 때 이런 퀴즈를 본 적이 있다.
하루에 두 번 정확한 시계가 있다. 이 시계는 어떤 시계일까?
그 당시 나는 이렇게 생각했다.
'시계가 빨라졌다가 느려졌다가 하기도 하나? 건전지 성능이 점점 떨어지면 그럴 수 있나?'
하지만 정답은 '고장난 시계'였다.
그때에도 나는, 나도 모르게, 시계가 어쨌든 동작은 하고 있다고 가정했나 보다