본문 바로가기

프로젝트 이야기/지니어스:모노레일

5편 : 싱글턴(Singleton) 없애기

4편에서는...

GUI 파트를 구현하였다. 

GUI 화면과 클래스 다이어그램

 

스크린에서 popTile (타일 삭제) 구현하기

시스탬 내부의 입장에서 보면 크게 두가지 동작이 있다고 할 수 있다. 첫째는 내부에서만 처리되는 동작이고, 둘째는 외부(GUI)와 메세지를 주고 받으면 처리되는 동작이다. 예를 들어 예외처리(HandleError)는 시스템 내부에서만 요청하여 시스템 내부에서 처리되지만 타일삽입(PutTile), 턴종료(EndTurn)과 같은 Playstrategy는 외부의 클릭 이벤트를 받아 내부에서 처리한다. 그리고 문제의 타일삭제(PopTile)가 나오게 된다.

 

타일삭제는 위에 언급했던 PlayStrategy와는 다르게 내부에서 요청하고 외부(GUI)에서 처리해야 한다. 쉽게 말해 시스템 내부에서 popTile() 메서드를 통해 타일을 삭제하면 외부 이미지에서도 타일이 없어져야 한다. 그러면서 생기는 고민이 바로 시스템 내부(MainSystem)가 어떻게 GUI(MainGUI)에게 요청할 것인가 이다.

 

싱글턴 패턴(Singleton Pattern)의 남용과 문제점

위와 같은 이슈가 발생한 적이 한번 있다. 바로 Player와 MainSystem 간의 쌍방향 요청이 발생한 경우다. MainSystem은 메세지에 따라 Player에게 게임을 플레이하라고 요청한다. 그러면  Player 객체는 게임(play())을 한 후 validCheck() 메서드를 통해 다시 MainSystem에게 하나의 액션을 정리(endAction)하라고 요청한다. 이때의 경우 이 문제를 해결하기 위해 MainSystem을 싱글턴 패턴으로 작성하여 Player가 MainSystem 객체에 접근할 수 있도록 하였다.

 

그러면 같은 방법으로 위의 상황에서도 MainGUI 객체를 싱글턴 패턴으로 만들어 외부에서 접근할 수 있도록 하여 MainSystem이 MainGUI에게 요청하면 될것이다. 하지만 이렇게 되면 벌써 싱글턴 패턴의 객체가 4개가 되게 된다! (Board/ImgStore/MainSystem/MainGUI)

 

"싱글턴을 자주 써도 되나?" 라는 불안감이 스멀스멀 올라오게 된것이다.

노란색은 다 싱글턴 패턴이다.
싱글턴 문제점이라고 구글에 치니 수두룩한 글들이 나와버렸다... (출처 : 네이버웹툰-대학일기)

 

싱글턴 패턴(Singlonton Pattern) 자주 써도 되나요?

결론부터 말하면 안된다. 싱글턴 패턴은 어디서나 접근가능한 '전역상태'의 패턴이다. 만약 싱글턴 패턴의 객체가 너무 하는 일이 많고, 심지어 데이터까지 공유한다면 객체간의 결합도가 높아지게 되고, 확장은 어렵고 수정은 자주 일어나게 되는 개방-폐쇄의 원칙에 어긋나는 코드가 되게 된다. 특히 멀티스레드 환경에서 사용할 경우, 동기화 이슈로 인해 단 하나의 객체만 생성되어야 한다는 싱글턴 패턴의 규칙이 무너질 수 있다.

 

메서드를 리턴값으로 교체해 싱글턴 없애기

위에서 언급한것 처럼 필요하지 않은 경우 싱글턴은 사용하지 않는게 좋다. 그렇다면 싱글턴이 필요가 없는 경우는 어떤 경우일까? 대표적으로 요청이 다른 요청을 유발하는 케이스이다. 아래 예시 코드를 보자.

public class A {
	
    private static A instance = new A();
    private int data;
    
    private A(int data) { this.data = data; }
    
    public static void getInstance() { return instance; }
    
    public void print(int x) { System.out.println(x); }
    
    public void plustAmount() { 
    	B b = new B();
        b.plus(data); 
	}
}

public class B {
	
    private int amount;
    
    public void plus(int x) {
   		A.getInstance().print(x + amount);
    }
}

A 객체는 싱글턴 패턴으로 만들어졌다. 이때 A의 plusAmount 메소드는 B 객체를 생성해 B의 plus 메서드를 호출한다. 그리고 호출된 plus 메서드는 다시 A 객체의 print() 메서드를 호출한다. 

 

그렇다면 B가 A에게 요청하게된 원인을 보자. 이는 A가 B에게 plus() 메서드를 요청했기 때문이다. 이런 경우는 B가 A에게 메서드를 요청하기 보다는 인자값을 리턴값으로 주어 A에서 처리해도 무관하다.

public class A {

    private int data;
    
    public A(int data) { this.data = data; }

    private void print(int x) { System.out.println(x); }
    
    public void plustAmount() { 
    	B b = new B();
        int result = b.plus(data);
        print(result);
	}
}

public class B {
	
    private int amount;
    
    public int plus(int x) {
   		return x + amount;
    }
}

이것은 다시 고친 코드이다. B 객체의 plus() 메서드는 A의 print() 메서드를 호출하는 대신 print할 인자값만 A에게 리턴한다. 그러면 인자값을 이용하여 A는 자신의 print() 메서드를 이용하여 결과값을 출력한다. 이럴 경우 A는 싱글턴일 이유가 없다.

 

내 코드에서 살펴보면...

ExecuteChecker.java

package checker;

import system.System;

public class ExecuteChecker {

    public void execute(String action) {
        CheckerFactory checkerFactory = new CheckerFactory();
        System.State result = System.State.NONE;

        switch(action) {
            case "PutTile": 
                result = checkerFactory.createChecker("OneTileChecker").check();
                // detail
                break;                
            
            /* 
            	생략....
            */

        }

        System.getInstance().endAction(result);
    }
}

 

ExecuteChecker 객체는 System이 생성한 PlayerTrigger 내부의 Player 객체가 생성하는 객체다.(말로 쓰니 복잡하다;;) 즉, MainSystem의 내부 객체라고 할 수 있다. 그리고 ExecuteChecker 객체는 MainSystem의 endAction()을 호출한다. 요청이 순환하는 구조가 발생하게 된다. 

 

사실 이렇게 코드를 구성한데는 이유가 있다. 바로 리턴으로 함수를 구성하게 되면 중간 과정에서 의미 없은 리턴을 반반복 해야 한다.

 

이걸 원하는데...
이렇게 해야한다...

하지만 프로그램의 규모가 커지자 리턴의 반복보다 싱글턴으로 인한 순환구조가 더 심각한 문제로 다가왔고 endAction은 MainSystem이 실행시키고 ExecuteChecker 객체는 endAction에 필요한 인자값을 리턴해주는 방식으로 코드를 수정했다.

result = result.stream().filter(s -> s != MainSystem.State.NONE).toList();

if (result.size() == 0) {
      System.out.println(MainSystem.State.NONE);
      return MainSystem.State.NONE;
      //MainSystem.getInstance().endAction(MainSystem.State.NONE);
} else { 
      System.out.println(result.get(0));
      return result.get(0);
      //MainSystem.getInstance().endAction(result.get(0));
}

위 코드는 ExecuteChecker 에서 최종 결과값을 정리하는 코드이다. endAction을 호출하는 대신 endAction의 인자값인 MainSystem.State 타입의 result값을 리턴해준다.

 

MainSystem.java

public boolean userBeginAction(String message) {
        
        if (lock.getLock()) {
            lock.closeLock();
            PlayTrigger playTrigger = new PlayTrigger();
            State endState = playTrigger.trig(players.get(curPlayer), message);
            
            boolean ret;

            if (endState == State.NONE || endState == State.TURN_END || endState == State.GAME_END) { ret = true; } 
            else { ret = false; }

            endAction(endState);
            return ret;
        }

        return false;
    }

위 코드는 MainSystem의 UserBeginAction 메서드이다. 내부 블럭을 보면 endState를 playerTrigger 객체에게 리턴 받는다.(ExecuteChecker의 check() 메서드에 의해 리턴된 값이다.) 그리고 내부 메서드인 endAction을 호출한다. 이로 인해 더이상 MainSystem은 싱글턴일 필요가 없다.

 

popTile의 구현

다시 돌아와 popTile을 구현해보자. 이제는 쉽다. MainSystem의 ...beginAction()메서드들은 playTrigger에게 받는 결과 값을 다시 MainGUI에게 준다. MainGUI는 해당 정보를 가지고 GUI의 이미지를 변환한다.

 

ContorolButton.java

package gui;

import javax.swing.*;
import java.util.*;

import system.MainSystem;

public abstract class ControlButton {

    MainSystem mainSystem;

    public abstract JButton getButton();
    public abstract JButton getButton(int posx, int posy);

    public ControlButton(MainSystem mainSystem) { this.mainSystem = mainSystem; }

    public JButton getSubButton(int idx) {
        return new JButton();
    }
    public JButton getSubButton() {
        return new JButton();
    }

    public boolean sendMessage(MainSystem mainSystem, String message) {
        return mainSystem.userBeginAction(message);
    }
    

    public boolean sendMessage(MainSystem mainSystem, String message, List<Integer> args) {
        return mainSystem.userBeginAction(message, args);
    }
}

위 코드는 컨트롤 버튼들의 추상클래스이다. 기존 코드의 경우 인터페이스였으나 필드값이 필요해 추상클래스로 변경하였다. 더 이상 MainSystem은 싱글턴이 아니므로 ControlButton 객체의 내부 필드값으로 갖는다. 그리고 sendMessage 메서드 호출시 mainSystem을 인자값으로 주어 mainSystem 객체의 userBeginAction 메서드를 호출한다. 이 메서드는 결과가 오류(...ERROR)일 경우 false를 리턴하고, 아닌경우 true를 리턴한다. 그 결과 값에 따라 controlButton을 상속받은 각 객체들이 알아서 오류 처리코드를 실행한다.

 

Ground.java

groundBtn[i].addActionListener(new ActionListener() {
	public void actionPerformed(ActionEvent e) {
    	JButton b = (JButton)e.getSource();
        int pos = Integer.parseInt(b.getName());

		if (pos == -1) {
        	return;
        }                    
        // add arguments
        List<Integer> args = new ArrayList<>();
        args.add(pos/17); args.add(pos%17);

		// COMPLETE 타일타입 예외처리
        if (selectMode.getTileType() == -1) { new ErrorWindow(); return; }
        args.add(selectMode.getTileType());                                       

		boolean result = sendMessage(mainSystem, "PutTile", args);

		if (result) {
       		b.setIcon(ImgStore.getInstance().getRail(selectMode.getTileType()));
        	b.setName("-1");
        }
        selectMode.initTileType();

	}
});

다음은 땅타일버튼의 clickevent를 처리하는 코드이다. 클릭시 타일을 놓는 것이므로 'PutTile' 메세지를 필요한 인자와 함께 mainSystem으로 보낸다. 이때 타일놓기에 성공했다면 sendMessage 메서드는 true를 리턴해 실제 타일 이미지를 철로 이미지로 바꾼다. 만약 실패했다면 아무일도 일어나지 않는다.

타일놓기 성공시, 땅미지가 철로이미지로 바뀜

MainGUI.java

    private MainSystem mainSystem = new MainSystem();

    private JButton[] deco = new JButton[17];
    private Ground ground = new Ground(mainSystem);
    private EndTurnButton endTurnButton = new EndTurnButton(mainSystem);
    private DeclareImpButton declareImpButton = new DeclareImpButton(mainSystem);

다음은 MainGUI의 필드선언 파트이다. mainSystem 객체를 필도로 선언후 각 버튼의 생성자의 인자값으로 넣어주어 모든 버튼이 하나의 mainSystem 객체를 사용하도록 하였다.

 

 

결론

처음 PopTile을 GUI상에 구현하고자 했을때는 MainSystem과 MainGUI가 모두 싱글턴으로 만들어지는 대참사(?)가 일어날뻔 했다. 왜냐하면 모든 메세지의 이동을 메서드 호출로 처리하려고 했기 때문이다. 그게 실제 코딩과 이후 보기에는 좋지 몰라도 정적 객체의 남용과 그로 인한 객체지향 정신의 위배를 불러일으킨다. 메세지(요청)이 유발하는 메세지(요청)의 경우 한 객체가 메세지를 메서드로 보내면 상대 객체는 리턴값으로 메세지를 보내는 것이 좋다. 실제로 위 상황에서는 그 방법을 통해 남용되는 싱글턴 패턴들을 제거하였다.