Java Refactoring -13, 놓치기 쉬운 싱글톤 오류 개선

박태건·2021년 7월 23일
0

리팩토링-자바

목록 보기
13/13
post-thumbnail

레거시 코드를 클린 코드로 누구나 쉽게, 리팩토링

위 책을 보면서 정리한 글입니다.

놓치기 쉬운 싱글톤 오류 개선

쉽게 생각하고 사용한 싱글톤은 프로젝트를 더 어렵게 만든다.

  • 코드를 구현하면서 각종 상황에 필요한 데이터를 저장하거나 저장된 데이터를 꺼내어 사용하는 경우가 생긴다.

  • 이 경우, 파일이나 DBMS를 사용하는데, 적지 않은 라이브러리의 도움을 받아야 하고 때로는 필요한 요소보다 더 많은 예외 사항이나 추가 설계를 수반한다.


  • 담아야 할 데이터의 양이 많지 않을 때에는 가볍게 상숑 가능한 전역 변수에 데이터를 담아 사용하기도 하지만, 이것 역시 단점이 있다.

  • 데이터 구조가 요구사항을 구현하기 힘들고, 예상치 못한 곳에서 데이터가 조작될 수 있기 때문에 신뢰성이 떨어진다.

    • 따라서 별도로 구현체의 모든 영역에서 사용할 데이터를 관리할 수 있는 객체
    • 싱글톤을 대안책으로 사용 가능.

  • 그러나, 대안책으로 사용하는 싱글톤 객체 역시 멀티 스레드 환경에서는 잘못된 데이터가 저장되기도 하고, 객체의 잘못된 생명주기 관리로 애플리케이션 장애가 생기는 문제점이 있다.

개선방향

싱글톤 객체를 좀 더 단단하게 만들어야 한다.

객실 예약 프로그램에서, 예약 가능한 객실 목록에 대한 정보를 관리하는 싱글톤 객체가 있을 때.

  • 객실 예약 데이터 관리의 Thread-safe가 보장되지 않으면 다중 접속 시 데이터가 의도치 않게 변경될 수 있다.
  • 또한, 예약 마감 후, 데이터를 변경 불가능하도록 Lock을 걸어놔야 하는데 접근 제한을 설정하지 않을 경우, 데이터가 마감 이후에도 변경될 수 있다.

앞의 문제 들은 싱글톤 객체를 Thared-Safe로 관리하고, 데이터 초기화가 가능하도록 개선하면 된다.

질문답

객체의 생명주기는 무엇인가

  • 객체에도 생명 주기가 있다.
    • 보통 객체의 생성부터 GD(Garbage Collection)에 의한 메모리 해제까지를 생명주기라고 한다.
    • 싱글톤 객체는 시스템에 상주해야 하는 중요한 데이터를 관리하는 객체로 생각하지만, 필요에 따라서는 객체에 데이터를 재설정하거나 메모리 해제를 해야하는 경우가 있다.

    • 그렇지만 대부분 특정 시점에서 데이터를 재설정하는 코드가 없는 경우가 많고,
    • 싱글톤 데이터를 함부로 재설정하거나 인스턴스를 새로 생성하면 멀티스레드 환경에서는 애플리케이션 장애를 초래할 수 있다.
  • 제한적 상태에서만 객체를 초기화해야 하므로 이런 조건을 어기면 싱글톤 객체의 치명적인 단점이 된다.

Thread-safe에 의한 데이터 관리는 어떤 것인가

  • 멀티스레드 환경에서 데이터에 접근할 때 동기화를 관리하는 방법을 의미한다.
    • 자바에서는 synchronized라는 예약어르 동기화 관리가 필요한 객체나 메서드에 접근해서 사용 가능.
    • (잘못 사용하면 Dead rock(Or Rece condition)에 빠져 객체 자체를 사용 못할 수 있다.)
  • 이 장에서는 Java API를 활용한 Thread-safe로 데이터를 관리

레거시 코드

HotelRooms 클래스 (Singleton)

  • 싱글톤 인스턴스를 관리
    • 데이터는 Map 형태의 자료구조로 정의된 객체에 있으면 접근 가능한 인터페이스를 제공.
    • 자료구조에서 날짜별 객실 정보가 관리되고, 예약 상태를 확인하여 예약 설정 가능.
public class HotelRooms {

    // 준비된 객실
    private static final Room[] READY_ROOMS = {
        new Room("101", Room.ReserveState.IDEL),
        new Room("102", Room.ReserveState.IDEL),
        new Room("103", Room.ReserveState.IDEL),
        new Room("104", Room.ReserveState.IDEL),
        new Room("201", Room.ReserveState.IDEL),
        new Room("202", Room.ReserveState.IDEL),
        new Room("203", Room.ReserveState.IDEL),
        new Room("204", Room.ReserveState.IDEL)
    };

    // 싱글톤 인스턴스
    private static HotelRooms hotelRooms;
    private Map<Date, List<Room>> rooms;
    
    // 싱글톤 인스턴스 접근 메서드
    public static HotelRooms getInstance() {
        if(hotelRooms == null) {
            hotelRooms = new HotelRooms();
            hotelRooms.rooms = new HashMap<Date, List<Room>>();
        }
        return hotelRooms;
    }

    // 특정 객실의 정보 바꾸기
    public boolean putReservedRoom(Data date, String roomNuber) {
        List<Room> roomsAtDate = rooms.get(date);
        if(roomsAtDate == null) {
            rooms.put(date, Arrays.asList(Arrays.copyOf(READY_ROOMS, READY_ROOMS.length)));
            roomsAtDate = rooms.get(date);
        }

        for(int i=0, size= roomsAtDate.size(); i < size; ++i) {
            Room room = roomsAtDate.get(i);
            if(room.getRoomNumber().equals(roomNumber)) {
                if(room.getReserveState() == Room.ReserveState.IDLE) {
                    // 해당 날짜에 같은 객실이 비어 있으면 예약 상태로 변경
                    room.setReserveState(Room.ReserveState.RESERVED);
                    return true;
                } else {
                    return false;
                }
            }
        }

        return false;
    }

    // 객실 정보 데이터를 전부 가져오기
    public Map<Date, List<Room>> getAllReservedRoom() {
        return rooms;
    }

    // 해당 날짜에 비어있는 객실이 있는지 확인
    public boolean containIdleRoomAtDate(Date date, String roomNumber) {
        List<Room> roomsAtDate = room.get(date);
        if(roomsAtDate == null) {
            rooms.put(date, Arrays.asList(Arrays.copyOf(READY_ROOMS, READY_ROOMS.length)));
            roomsAtDate = rooms.get(date);
        }

        for(int i=0, size = roomsAtDate.size(); i < size; ++i) {
            Room room = roomsAtDate.get(i);
            if(room.getRoomNumber().equlas(roomNumber)) {
                if(room.getReserveState() == Room.ReserveState.IDLE) {
                    // 해당 날짜에 같은 객실이 비어있는지 확인
                    return true;
                } else {
                    return false;
                }
            }
        }
    }
}

Room 클래스

  • 객실 정보를 가진 객체로, 객실 번호와 예약 상태가 들어 있다.
  • 단순히 객실 번호와 예약 상태만을 관리하는 데이터 클래스.
public class Room {
    public enum ReserveState {
        IDLE, RESERVED;
    }

    // 객실 번호
    private String roomNumber;
    // 예약 상태
    private ReserveState reserveState;

    public Room(String roomNumber, ReserveState reserveState) {
        this.roomNumber = roomNumber;
        this.reserveState = reserveState;
    }

    public String getRoomNumber() {
        return roomNumber;
    }

    public void setRoomNumber(String roomNumber) {
        this.roomNumber = roomNumber;
    }

    public ReserveState getReserveState() {
        return reserveState;
    }

    public void setReserveState(ReserveState reserveState) {
        this.reserveState = reserveState;
    }
}

InquireRoomVo 클래스

  • 예약을 관리하는 비즈니스 로직을 가진 객체.
  • 싱글톤 객체인 HotelRooms 클래스에 접근하여 객실 예약 상태를 확인하고 객실을 예약.
public class ReserveService {
    public boolean reserve(Date date, String roomNumber) {
        HotelRooms roomData = HotelRooms.getInstance();
        boolean isReserve = roomData.putReservedRoom(date, roomNumber);
        return isReserve;
    }

    public List<Room> getReservedRoomAtDate(Date date) {
        HotelRooms roomData = HotelRooms.getInstance();
        Map<Date, List<Room>> allReservedRoom = roomData.getAllReservedRoom();
        return allReservedRoom.get(date);
    }
}

레거시 코드 개선 과정

다음과 같은 단계로 개선

  1. 싱글톤 객체의 생성자에 대한 개선.
  2. 싱글톤 객체에서 제공하는 정보의 초기화 시점에 대한 개선.
  3. 동시 접근성 예외 사항 개선.
  4. 처리 메서드에서 인스턴스 생성을 메서드 인자로 전환하여 개선.
  5. 싱글톤 객체를 초기화하는 메서드 구현.

싱글톤 객체의 생성자에 대한 개선

  • 싱글톤 만들기의 첫 번째 단계.
  • 외부에서는 생성자에 접근할 수 없도록 private 클래스명으로 수정.
    • HotelRooms.getInstance()에서 인스턴스가 Null일 때에는 내부에서만 접근 가능한 생성자로 초기화하도록 지정.
    • 생성자 역시 동시에 접근 가능할 수 있으므로 Thared-safe를 위한 synchronized 예약어 사용.
public class HotelRooms {


    // 싱글톤 인스턴스
    private static HotelRooms hotelRooms;
    private Map<Date, List<Room>> rooms;
    
    // 싱글톤 인스턴스 접근 메서드
    synchronized public static HotelRooms getInstance() {
        if(hotelRooms == null) {
            hotelRooms = new HotelRooms();            
        }
        return hotelRooms;
    }

    // 싱글톤 접근 제한 생성자
    private HotelRooms() {
        rooms = new HashMap<Date, List<Room>>();
    }

    // 중략
}

싱글톤 객체에서 제공하는 정보 초기화 시점에 대한 개선

  • 게으른 초기화(Lazy Initialization)가 코드에 산발적으로 있으면 특정 데이터에 대해서는 접근을 막거나 초기화해야 할 때 의도하지 않은 곳에서 데이터를 다르게 생성해야 하는 문제가 발생 가능.
  • 따라서 게으른 초기화도 데이터의 생명주기를 위해 편하게 관리해야 한다.

  • HotelRooms 클래스에서는 게으른 초기화를 관리하기 위해 getRoomAtDate() 메서드로 특정 날짜의 데이터에 접근할 때 지정된 메서드로 데이터를 가져오게 한다.
public class HotelRooms {


    // 특정 객실의 정보 바꾸기
    public boolean putReservedRoom(Data date, String roomNuber) {
        List<Room> roomsAtDate = getRoomAtDate(date);

        // 중략
        return false;
    }

    // 해당 날짜에 비어있는 객실이 있는지 확인
    public boolean containIdleRoomAtDate(Date date, String roomNumber) {
        List<Room> roomsAtDate = getRoomAtDate(date);
        
        // 중략
        return false;
    }

    // 특정 날짜의 데이터 가져오기
    private List<Room> getRoomAtDate(Date date) {
        List<Room> roomsAtDate = rooms.get(date);
        if(roomsAtDate == null) {
            // 데이터가 없으면 초기화
            rooms.put(date, Arrays.asList(Arrays.copyOf(READY_ROOMS, READY_ROOMS.length)));
            roomsAtDate = rooms.get(date);
        }

        return roomsAtDate;
    }
}

동시 접근성 예외 사항 개선

자바에서는 Thread-safe로 변경 가능한 다양한 API를 제공

  1. synchronized 예약어로 메서드나 객체에 접근하여 Block 단위로 관리
  2. Concurrency API를 활용(Java 1.5 이상)
  3. Collection 데이터 객체 자체를 Thread-safe로 선언(Java 1.2이상)
    예시.
List<String> synchronizedList = Collections.synchronizedMap(new ArrayList<String>());
  • 여기서는 1번과 2번을 적절히 섞어서 사용.
  • 초기화하는 메서드에 대한 접근과 날짜변 각 객실 데이터에 대한 접근은 1번.
  • 객실 정보를 관리하는 데이터는 2번.

  • 날짜별 객실 정보를 관리하는 rooms와 각 날짜에 해당하는 객실 배열을 Collectins.synchronized() 메서드를 사용하여 동기화 객체로 선언.
  • 객체에 대한 초기화가 포함된 메서드인 getRoomsAtDate()도 synchronized로 선언하여 동시에 접근해도 한 번만 초기화가 된다.
  • 날짜별 각 객실에 대한 데이터도 접근과 변경이 동시에 될 수 있으므로 synchronized Block을 설정하여 동시 접근을 제한할 수 있게 수정.
import java.util.Collections;

public class HotelRooms {

   // 중략
    private Map<Date, List<Room>> rooms;
    
    // 싱글톤 접근 제한 생성자
    private HotelRooms() {
        // rooms를 동기화 객체로 선언
        rooms = Collections.synchronizedMap(new HashMap<Date, List<Room>>());
    }

   // 특정 날짜의 데이터 가져오기
   // 여러 곳에서 접근하더라도 초기화는 한 번만 하게 하기 위함
   private List<Room> getRoomAtDate(Date date) {
        List<Room> roomsAtDate = rooms.get(date);
        if(roomsAtDate == null) {
            // 데이터가 없으면 초기화
            roomsAtDate = Arrays.asList(Arrays.copyOf(READY_ROOMS, READY_ROOMS.length));
            rooms.put(date, roomsAtDate);
            
        }
        return roomsAtDate;
    }

    // 특정 객실의 정보 바꾸기
    public boolean putReservedRoom(Data date, String roomNuber) {
        List<Room> roomsAtDate = getRoomAtDate(date);
        for(int i=0, size= roomsAtDate.size(); i < size; ++i) {
            Room room = roomsAtDate.get(i);
            synchronized(room) {
                // 객실 데이터에 접근하므로 객실에 대한 동기화 관리도 필요
                if(room.getRoomNumber().equals(roomNumber)) {
                    if(room.getReserveState() == Room.ReserveState.IDLE) {
                        // 해당 날짜에 같은 객실이 비어 있으면 예약 상태로 변경
                        room.setReserveState(Room.ReserveState.RESERVED);
                        return true;
                    } else {
                        return false;
                    }
                }
            }
        }
        return false;
    }
}

기존 클래스의 메서드를 수정

InquireRequestModel 단위 테스트

InquireRequestModel의 requset() 메서드의 오버로딩 처리를 염두에 두고 단위 테스트 작성

public class InquireRqeusetModelTest {

    @Test
    public void testRequest_비즈니스룸_조회() {
        // Given
        Parser parser = new BusinessInquireRoomParser();
        String checkInDate = "2014-04-27";
        String checkOutDate = "2014-04-29";
        int type = 1;

        // When
        Object InquireData = InquireRequestModel.request(checkInDate, checkOutDate, parser);

        // Then
        Assert.assertEquals(BusinessRoomVO.class, inquireData.getClass());
    }
}

HotelRoomsTset 클래스

Thread-safe가 보장되는지 테스트

  • 멀티 스레드 환경에서는 JUnit 테스트가 제한적이므로 아래와 같은 테스트 코드로 작성해야 한다.
  • 8개의 객실을 10ms 간격으로 예약하고 테스트용 예약 목록에 넣는다.
  • 이런 스레드를 30개를 동시 실행하고, 예약된 객실을 또 예약하면 테스트는 실패한다.
import java.util.Collections;

public class HotelRoomsTest {
    private final static String[] ROOMS_NUMS = {"201" "202", "203", "204", "101", "102", "103", "104"};
    private final static Date CURRENT = new Date();

    @Test
    public void testPutReservedRoom() throws Exception {
        final List<String> reservingRooms = Collections.synchronizedList(new ArrayList<String>());
        for(int idx = 0; idx < 30; ++idx) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    HotelRooms instance = HotelRooms.getInstance();
                    for(int idx = 0; idx < ROOM_NUMS.length; ++idx) {
                        boolean isReserving = instance.testPutReservedRoom(CURRENT, ROOM_NUMS[idx]);

                        try {
                            Thread.sleep(10);
                        } catch(InterruptedException e) {
                            e.printStackTrace();
                        }
                    }

                    if(isReserving) {
                        if(reesrvingRooms.contains(ROOMS_NUM[idx])) {
                            Assert.fail(ROOM_NUMS[idx] + " is Reserved");
                        } else {
                            reservingRooms.add(ROOM_NUMS[idx]);
                        }
                    }
                }
            });
            t.start();
        }
        Thread.sleep(1000);
    }
}

처리 메서드에서 인스턴스 생성을 메서드 인자로 전환하여 개선

싱글톤에 접근하는 인터페이스가 전역으로 선언되어 있으므로 사용하는 객체에서 접역 접근 함수인 getInstance() 메서드를 가지고 싱글톤 객체를 사용.

  • 전역 접근 함수 인터페이스가 변경되면 해당 함수를 사용하는 모든 객체가 수정된다.
  • 따라서, 싱글톤 인스턴스를 사용하는 곳에서는 전역 접근 함수를 사용하는 것보다 객체를 참조 변수로 받아서 처리하는 것이 좋은 방법.

메서드에서 직접 인스턴스에 접근하던 것을 매개변수로 받아 처리하도록 수정하면 싱글톤 객체의 접근 인터페이스가 변경되어도 수정 없이 객체를 사용 가능.

public class ReserveService {
    public boolean reserve(Date date, String roomNumber, HotelRooms hotelRooms) {
        boolean isReserve = hotelRooms.putReservedRoom(date, roomNumber);
        return isReserve;
    }

    public List<Room> getReservedRoomAtDate(Date date, HotelRooms roomData) {
        Map<Date, List<Room>> allReservedRoom = roomData.getAllReservedRoom();
        return allReservedRoom.get(date);
    }
}

싱글톤 객체를 초기화하는 메서드 구현

싱글톤 객체는 메모리 영역에 상주하는 객체이다. 따라서 필요 없는 시점이 되면 불필요한 메모리 사용을 최소화 하기 위해 객체를 초기화하거나 해제해야 한다.

  • 싱글톤 객체를 다시 초기화하거나 인스턴스를 없애는 방법은 제한적인 부분이 많다.
  • 인스턴스가 없어지는 시점을 정확히 알 수 없고, 인스턴스가 없어지는 순간에 다른 곳에서 사용하면 치명적인 요류가 발생하고
  • 오류의 원인을 찾아내기도 힘들다.
  • 따라서, 객체의 인스턴스를 없애거나 초기화하는 방법은 정말 필요할 때에만 사용해야 한다.
public class HotelRooms {
    // 싱글톤 인스턴스
    private static HotelRooms hotelRooms;
    private Map<Date, Lisat<Room>> rooms;

    // 객체 초기화
    public static boolean release() {
        if(hotelRooms = null) {
            return true;
        }

        hotelRooms.rooms.clear();
        hotelRooms = null;
        return true;
    }
}

개선된 레거시 코드

HotelRooms 클래스

데이터를 동기화 객체로 선언(Thread-safe)하여 여러 객체에서 동시에 접근하더라도 데이터의 일관성이 유지되도록 수정.

  • 개별적인 데이터도 별도로 동기화 선언을 하여 한 번만 초기화되도록 한다.
  • 메모리를 해제할 수 있도록 인터페이스를 제공하여 유사시에는 객체를 초기화 할 수 있도록 개선.
  • 초기화의 해제가 선언되어 객체의 생명주기를 능동적으로 관리 가능.

ReserveService 클래스

  • 싱글톤 객체에 접근할 때 전역 함수를 통하지 않고 싱글톤 객체의 별도의 매개변수로 전달받아 싱글톤 객체의 접근 인터페이스가 변경되도록 수정.

요약 및 정리

싱글톤 객체는 효용성이 매우 강력하나, 그만큼 사용하기 매우 까다롭다.

  • 싱글톤 객체를 안전하게 사용하기 위해서는 Thread-safe를 보장하는 구조가 필요하고, 생명주기도 능동적으로 관리 가능해야 한다.

  • 싱글톤 객체에 접근하는 객체는 접근 인터페이스가 변경되었을 때 수정을 최소화하는 방법으로 사용되어야 한다.

  • 싱글톤 객체의 사용을 쉽게 생각하면 안 된다.
    - 싱글톤은 쉽게 정보를 저장하고 사용 가능하지만, 그만큼 고려 사항도 많다.

싱글톤의 잠재적 오류를 개선하기 위한 생각의 흐름

  1. 객체 초기화 접근자가 제한되어 있는지를 확인.
  2. 싱글톤 내의 데이터가 동기화 관리가 되고 있는지 확인하고, 메스더 동기화와 데이터 동기화를 생각.
  3. 싱글톤 객체의 생명주기 관리가 능동적인지 확인.
  4. 싱글톤 객체에 접근하는 객체가 접근 인터페이스로부터 영향을 받는 부분이 없는지 확인.
profile
노드 리액트 스프링 자바 등 웹개발에 관심이 많은 초보 개발자 입니다

0개의 댓글