이번 학기 첫 자바 실습으로 Battle 리팩토링을 하게 되었다. 거대한 반복문을 풀어내고, 각 기능들을 역할에 맞게 모듈화하면서 '좋은 코드란 무엇일까'를 고민했던 과정을 기록해 보려 한다.
현재 코드 상태
현재 코드에서 가장 큰 문제는 코드 중복이다. 너무나 거대한 반복문, 매번 반복되는 출력문과 랜덤수 생성 코드 등 모듈화가 전혀 되어 있지 않았다. 추가적으로 메소드 오버라이딩이 의미없게 작성되어있거나 위치가 적절하지 않는 것과 같이 상속과 관련된 문제도 있었다.
수정할 부분들 : before / after
아래 코드를 보면 if-else if문이 나열되어 있는데 막상 조건문 안의 내용을 보면 전부 똑같은 코드인 것을 확인할 수 있다. 따라서 의미없이 나열되어 있는 조건문들을 정리하였다.
Before:
// Player.java
if ((int) (Math.random() * 1000) % 100 <(this.accuracy-target.dodge)) {
Mywin.ta.append("공격이 성공했습니다!\n");
System.out.println("공격이 성공했습니다!");
int r=(int) (Math.random() * 1000) % 6;
if(r==0) {
target.hp-=(this.power-target.protection);
System.out.printf("%s의 공격이 %s에게 %d 만큼의 데미지를 입혔습니다!",this.name,target.name,this.power-target.protection);
System.out.println();
String str= this.name +"의 공격이" + target.name+ "에게"+(this.power-target.protection)+"만큼의 데미지를 입혔습니다!\n";
Mywin.ta.append(str);
}
else if(r==1) {
target.hp-=(this.power-target.protection+1);
System.out.printf("%s의 공격이 %s에게 %d 만큼의 데미지를 입혔습니다!",this.name,target.name,this.power-target.protection+1);
System.out.println();
String str = this.name +"의 공격이" + target.name+ "에게"+(this.power-target.protection+1)+"만큼의 데미지를 입혔습니다!\n";
Mywin.ta.append(str);
}
// .. 중략
if(target instanceof Heroes) {
r=(int) (Math.random() * 1000) % 11;
target.stress+=r;
System.out.printf("공격으로 인해 %s의 스트레스가 %d만큼 증가합니다!",target.name,r);
System.out.println();
String str = "공격으로 인해"+target.name+ "의 스트레스가"+ r+"만큼 증가합니다!\n";
Mywin.ta.append(str);
}
}
else {
System.out.println("공격이 빗나갔습니다!");
String str = "공격이 빗나갔습니다!\n";
Mywin.ta.append(str);
}
}
After:
public void attack(Player target){
if(GetRan.ranNum(100)<(this.accuracy-target.dodge)) {
PrintSen.printSen("공격이 성공했습니다!\n");
int r = GetRan.ranNum(6);
target.hp-=(this.power-target.protection+r);
PrintSen.printSen(this.name +"의 공격이" + target.name+ "에게"+(this.power-target.protection+r)+"만큼의 데미지를 입혔습니다!\n");
if(target instanceof Heroes) {
int r=GetRan.ranNum(11);
target.stress+=r;
PrintSen.printSen("공격으로 인해"+target.name+ "의 스트레스가"+ r+"만큼 증가합니다!\n");
}
} else {
PrintSen.printSen("공격이 빗나갔습니다!\n");
}
}
또한 많은 코드에서 아래 코드와 같이 비슷한 출력문과 랜덤수를 반복해서 사용하고 있다. 계속 똑같은 코드들을 중복하여 사용하기보단 static 메소드로 만들어 모듈화하였다.
Before:
// Vestal.java
if ((int) (Math.random() * 1000) % 100 <(this.accuracy)) {
System.out.println("치료가 성공했습니다!");
String str = "치료가 성공했습니다!\n";
Mywin.ta.append(str);
int r=(int) (Math.random() * 1000) % 6;
if(r==0) {
target.hp+=r;
System.out.printf("%d만큼 치료되었습니다!",r);
System.out.println();
str = r+"만큼 치료되었습니다!\n";
Mywin.ta.append(str);
}
else if(r==1) {
target.hp+=r;
System.out.printf("%d만큼 치료되었습니다!",r);
System.out.println();
str = r+"만큼 치료되었습니다!\n";
Mywin.ta.append(str);
}
// ..중략
} else {
System.out.println("치료가 실패했습니다!");
String str ="치료가 실패했습니다!\n";
Mywin.ta.append(str);
}
After:
// 랜덤수 생성 메소드
public class GetRan {
public static int ranNum(int num) {
return (int) (Math.random() * 1000) % num;
}
}
// 출력문 전용 메소드
public class PrintSen {
public static void printSen(String sen) {
System.out.print(sen);
Mywin.ta.append(sen);
}
}
// 수정된 Vestal.java
public void attack(Player target){
this.신성한위무(target);
if(GetRan.ranNum(100)<this.accuracy) {
PrintSen.printSen("치료가 성공했습니다!\n");
int r= GetRan.ranNum(6);
target.hp+=r;
PrintSen.printSen(r+"만큼 치료되었습니다!\n");
} else {
PrintSen.printSen("치료가 실패했습니다!\n");
}
}
다음으로 상속 관련 문제들을 수정해보겠다. 원본 battle은 '(부모1)Player - (부모2)Heroes / Monster - (자식)하위 클래스들'의 상속 구조를 가지고 있다.
아래 코드는 위에서 수정한 Player 클래스의 attack() 메소드이다. 코드를 보면 if문 안에 Heroes 클래스인지 확인하는 조건문이 있다. 이 공격 기능은 공격 대상이 항상 Heroes인 Monster 객체들만 사용하는 기능이므로, Monster 클래스에서 메소드 오버라이딩을 하는 것이 상속 구조에 더 알맞는 형태이다. 따라서 Player 클래스에서 공격성공여부를 받아 그 결과에 따라 스트레스를 증가시키는 로직을 추가하도록 수정하였다.
Before:
// Player.java
public void attack(Player target){
if(GetRan.ranNum(100)<(this.accuracy-target.dodge)) {
PrintSen.printSen("공격이 성공했습니다!\n");
target.hp-=(this.power-target.protection+GetRan.ranNum(6));
PrintSen.printSen(this.name +"의 공격이" + target.name+ "에게"+(this.power-target.protection+GetRan.ranNum(6))+"만큼의 데미지를 입혔습니다!\n");
// Heroes 클래스인지 Player 클래스에서 비교하고 있다.
if(target instanceof Heroes) {
int r=GetRan.ranNum(11);
target.stress+=r;
PrintSen.printSen("공격으로 인해"+target.name+ "의 스트레스가"+ r+"만큼 증가합니다!\n");
}
} else {
PrintSen.printSen("공격이 빗나갔습니다!\n");
}
}
After:
// 공격 여부를 반환하는 Player.java
public boolean attack(Player target){
if(GetRan.ranNum(100)<(this.accuracy-target.dodge)) {
PrintSen.printSen("공격이 성공했습니다!\n");
int r = GetRan.ranNum(6);
target.hp-=(this.power-target.protection+r);
PrintSen.printSen(this.name +"의 공격이" + target.name+ "에게"+(this.power-target.protection+r)+"만큼의 데미지를 입혔습니다!\n");
return true;
} else {
PrintSen.printSen("공격이 빗나갔습니다!\n");
return false;
}
}
// 공격 성공 여부에 따라 스트레스 증가를 결정하는 Monster.java
public boolean attack(Heroes target) {
boolean attack_result = super.attack(target);
if(attack_result) {
int r = GetRan.ranNum(11);
target.stress+=r;
PrintSen.printSen(this.name + "의 공격으로 인해 " + target.name + "의 스트레스가 " + r + "만큼 증가합니다!\n");
}
return attack_result;
}
마지막으로 Player 클래스에 정의되어 있는 playerStress() 메소드를 Heroes 클래스에서 오버라이딩했지만 실제로는 Monster 객체만 playerStress()를 사용하고 있어 의미없는 오버라이딩이었다. 따라서 Heroes 클래스에 의미 없이 오버라이딩된 playerStress() 메소드는 혼동을 줄 수 있어 삭제했다.
개선된 점
가장 눈에 띄는 개선점은 당연 긴 if 조건문의 제거라고 생각한다. 쓸데없이 반복되는 조건문을 하나의 로직으로 일반화하여 추후 유지보수성이 향상되고 가독성이 좋아졌다. 또한 반복 사용하는 로직은 따로 메소드로 만들어 모듈화가 되어 추후 수정이 용이해졌다.
추가로 상속 구조에 맞게 메소드들을 수정하여 객체 지향의 원칙 중 하나인 "Tell, Don't Ask"를 지켰다.
이를 통해 Player 클래스가 자식 클래스(Heros, Monster)의 존재를 몰라도 되게 만들어 클래스 간의 결합도 또한 낮추었다.
느낀점 및 알아낸 점
무엇보다 일반화와 모듈화가 왜 중요한지 피부로 느낀 실습이었다. 모듈화가 전혀 없는 코드는 구조 파악이 어려울 뿐만 아니라, 작은 수정 하나가 거대한 연쇄 반응을 일으킬 수 있다는 것을 깨달았다. 왜 그토록 많은 개발자들이 모듈화를 강조하는지 직접 체감할 수 있었다.
또한 이번 실습은 올바른 상속 구조에 대해 깊게 고민하는 계기가 되었다. "부모 클래스에는 보편적인 기능을, 자식 클래스에는 고유한 기능을" 이라는 원칙에 따라 코드를 수정하며, 객체의 역할을 어디까지로 한정해야 하는지 명확하게 정의하는 훈련을 할 수 있었다.
마지막으로, 첫 리팩토링을 통해 객체 지향의 핵심 원칙인 "묻지 말고, 시켜라 (Tell, Don't Ask)"를 몸소 체험했다. 리팩토링 전 코드는 부모가 자식의 타입을 확인하며 모든 것을 처리해주는 구조라 클래스 간의 결합도가 높았다. 하지만 리팩토링 후에는 각 객체가 자신의 역할과 책임에만 집중하도록 변경했고, 그 결과 훨씬 유연하고 유지보수하기 좋은 구조가 만들어졌다. 이것이 바로 객체 지향 설계의 힘이라는 것을 깨닫는 좋은 기회였다.