JAVA) Enum과 인터페이스를 통한 리팩토링

2024. 3. 23. 03:31WEB/Refactoring

최근 프로젝트의 일부 설계를 리팩토링 한 경험이 있는데, 리팩토링 과정에서 겪었던 고민과 해결을 말로 풀어하려니 어려워서 글로 정리해 보고자 작성합니다..
 
우선 리팩토링 전부터 보겠습니다.

기존 코드
Image.java

 
프로젝트에는 ImageClient라는 객체가 있는데, 해당 객체가 각 플랫폼에 이미지 생성을 위한 프롬프트와 응답 포맷을 전송합니다.
이후 다양한 포맷으로 받은 응답 데이터를 담는데에 사용되는 객체입니다.
 

고영희
이미지 생성 API
response_format

해당 API 설명에 따르면 response_format에 url 또는 b64_json을 넣어 응답 객체를 선택할 수 있는데, 이를 기반으로 Image 클래스가 설계된 것 같습니다.
 
문제는 다른 모듈에서도 해당 클래스를 사용하고 있다는 것으로,

하
주석이 무슨 뜻인지 한참 고민했습니다..

OpenAI의 API에 맞추어 설계된 Image 객체를 사용하기 위해, url 필드를 byte[](e.g. png, jpeg) 값을 담는 용도로 사용하고 있었습니다..
 


문제점 파악

구조를 수정해야겠다고 생각하고, 정확한 문제점을 파악하기 위해 관련 코드를 다 읽어보았습니다.
우선 해당 클래스에서 파악한 문제는 다음과 같습니다.
 

  1. 각 Provider의 다른 API를 반영하지 못함
  2. Setter를 통해 잠재적인 문제 발생 가능
  3. 생성자에서 여러 필드에 대한 값을 한꺼번에 받음
  4. 추가적인 포맷에 대한 확장이 발생했을 때, 이를 반영하기 어려움

API 반영

우선 Image 객체를 통해, 데이터를 받더라도 데이터를 제대로 처리하지 못할 수 있는 문제가 있었습니다.
예를 들어 OpenAI의 b64_json을 통해 받은 결과물과, StabilityAi의 base64를 통해 받은 결과물은 모두 base64를 통해 인코딩 되어있습니다.
하지만 OpenAI의 경우, 이미지 데이터를 담고있는 json을 인코딩했으며 StabilityAI는 이미지 자체를 인코딩한 결과를 반환합니다.
나중에 이미지를 처리하는 모듈이 별도로 분리된다면 디코딩 이후 추가적인 분기가 발생하게 되며, 이러한 점이 장애물이 될 수 있다고 생각했습니다.
 

객체 불변성

해당 객체는 Setter를 통해 수정이 가능한 객체로, 잘못 사용할 경우 동시성 문제를 야기할 수 있습니다.
해당 부분은 별도의 Issue를 통해 따로 해결했습니다.
 

생성자와 Getter 동작 방식

해당 객체는 생성자에서 url과 base64에 대한 값을 동시에 받습니다.

Image image = new Image("https://..", null);

if (image.getUrl() != null) ...
else if (image.getBase64() != null) ...
else throw new RuntimeException(); ...

이는 null 체크에 대한 책임을 위와 같이 호출자에게 전가하게 됩니다.
 

추가적인 포맷에 대한 확장성

위 생성자와 관련해 새로운 포맷이 추가될 경우 생성자의 인자도 추가되어야 하며, null 체크를 해야 할 메서드도 증가하게 됩니다.
만약 StabilityAI의 API만 새로 적용한다고 해도 생성자는 4개의 인자를 받게되는데, 새로운 포맷이 한 개 추가된다면 기존 Image의 생성자가 호출되는 모든 부분에 null을 추가해야 합니다.
 


설계의 방향에 대한 고민

위와 같은 문제들을 해결하기 위해 다음과 같이 해결하고자 했습니다.

  1. Image 클래스를 인터페이스로 변경, 각 모듈에서 해당 인터페이스를 구현
  2. Enum을 사용해 Request의 response_format 속성과 Image를 더욱 안전하고 효율적으로 사용하도록 설계
  3. 확장성을 고려해 제네릭을 사용, base64 또는 url과 같은 String 이외의 값도 담을 수 있도록 설계

위 방향성을 적용해서

public interface Image<T> {

	/**
	 * Returns the image data. The type of the data is determined by the type parameter
	 * {@code T} of this interface.
	 * @return Image data of type T.
	 */
	T getData();

	/**
	 * Returns the type of the image. The image type is defined by the {@link ImageType}.
	 * Through this method, it's possible to know the type of the image.
	 * @return The {@link ImageType} of the image.
	 */
	ImageType getType();

}

 
우선 기존 클래스를 인터페이스로 변경하고 기존의 다양한 getter를 getData 하나로 합쳤습니다.
이를 통해 포맷이 추가되더라도, ImageType에 대해서만 수정이 발생하고 getData에는 아무런 변경이 발생하지 않습니다.
 
이어서 ImageType입니다.

public interface ImageType<T extends Enum<T> & ImageType<T, E>, E> {

	/**
	 * Returns the value associated with the enum constant.
	 * @return The value of the enum constant of type {@code E}.
	 */
	E getValue();

}

Enum을 사용하여 안정성을 추구하면서, 확장을 위해 Type을 인터페이스로 만들었습니다.
 
타입 인자의 바운더리에 대해 꽤 고민을 많이 했던 것 같습니다.
Enum 타입을 강제하지 않으면서 람다나 메소드 참조를 사용할 수 있도록 확장성을 추구할지, 아니면 Enum을 강제할지에 대한 고민이 가장 컸는데요
확장성을 조금 포기하더라도 Enum 타입의 정의를 강제하면 문서화를 강제할 수 있다는 생각이 들어, 결국은 Enum에 대한 바운더리를 설정했습니다. 이게 가장 컸습니다.
 
해당 프로젝트가 오픈소스인 만큼 많은 사람들이 사용하게 될 텐데, 코드의 "b64_json" 같은 속성을 각 플랫폼의 API 문서가 아닌 코드 내에서 확인할 수 있으면 좋지 않을까라는 생각을 했습니다.
또한 재귀적 타입 바운딩을 통해 T getDefault() 등의 메소드가 필요할 때, 더욱 안전하게 사용할 수 있도록 설계했습니다.
 
실제 구현된 예시 ImageType은 다음과 같습니다.

public enum OpenAiImageType implements ImageType<OpenAiImageType, String> {

	URL("url"), BASE64("b64_json");

	private final String value;

	OpenAiImageType(final String value) {
		this.value = value;
	}

	@Override
	public String getValue() {
		return this.value;
	}

	public static OpenAiImageType fromValue(final String value) {
		return Arrays.stream(values())
			.filter(v -> v.value.equals(value))
			.findAny()
			.orElseThrow(() -> new IllegalArgumentException("Invalid Value"));
	}

}

이미지의 타입을 확인하고, 요청을 보내는 부분에서도 response_format의 값을 기존 String이 아닌 Enum으로 대체할 수 있도록 했습니다.
 

public abstract class AbstractImage<T> implements Image<T> {

	private final T data;

	private final ImageType type;

	/**
	 * Constructs an AbstractImage with the specified data and image type.
	 * @param data the image data of this image.
	 * @param type the type of the image, as defined by the implementation of the
	 * ImageType interface.
	 */
	protected AbstractImage(final T data, final ImageType type) {
		this.data = data;
		this.type = type;
	}

	@Override
	public T getData() {
		return this.data;
	}

	@Override
	public ImageType getType() {
		return this.type;
	}
	...
}
public class OpenAiBase64Image extends AbstractImage<String> {

	public OpenAiBase64Image(final String b64Json) {
		super(b64Json, OpenAiImageType.BASE64);
	}

}

+ 추상 클래스를 각 모듈에서 구현하여 각 모듈의 이미지들에 대해서도 문서화를 유도하였습니다.
 
자바 경험이 많지 않았는데, 이번 기회에 제네릭과 enum에 대해 알아볼 수 있어 유익했던 것 같습니다..

반응형