개발관련/삽질

Java Enum 활용기(기본/활용/Spring/MyBatis/테스트까지)

동팡 2020. 7. 20. 22:50

목차

  • Enum 설명
  • Enum 기본
  • Enum 활용
  • Spring에서의 Enum활용(Message Converter)
  • Spring에서의 Enum활용(Validation)
  • MyBatis에서의 Enum활용(TypeHandler)
  • 해당 사항 테스트 코드

Enum 설명

고정된 상수 집합이 필요할 때 Enum을 제일 많이 사용한다. 

Java Enum은 Java 5부터 나왔다. Int Enum이나 String Enum을 대체하기 위해 나온 Java의 Enum기능은 강력하다.

보통 개발 할 때 아래와 같이 Int / String Enum을 많이 사용한다.

private static final int FRUIT_ORANGE = 1;
private static final int FRUIT_APPLE = 2;

private static final String OPERATION_MODULE_A = "op_a";
private static final String OPERATION_MODULE_B = "op_b";
private static final String OPERATION_MODULE_C = "op_c";

위와 같은 Enum들을 자주 봤을 것이다. 일단 얘기하자면 위와 같은 것은 Enum 안티 패턴이다.

아래와 같은 이유로 인해 Int/String Enum 패턴을 사용하면 프로그램이 쉽게 깨질 우려가 존재한다. 

  • 컴파일 시점의 상수 -> 상수의 유연한 핸들링이 힘들다.
  • 그룹핑 기능이 없기 때문에 복잡한 기능을 소화하지 못 한다.
  • Int Enum의 경우, 인쇄 가능한 문자열을 생성하기 피곤하다.
  • String Enum의 경우, 문자열 비교를 하기 때문에 느리다. 
  • 타입 안전성을 보장하지 못 한다.

즉, 정리하자면 타입 세이프하지 못 하다. 때문에, 컴파일 타임에서 오류를 잡을 수 없다. 런타임 테스트를 통해 오류를 확인할 수 있다(int 또는 String형 만 받으면 다 되기 때문에 문제점을 찾기 위한 뎁스가 추가된다). 또한 확장성이 많이 부족하기 때문에 활용하는데 있어, 한계점이 존재할 수 밖에 없다(다만, 컴파일 시점 상수이기 때문에, 어노테이션에 쉽게 적용할 수 있다ㅎㅎ, Enum의 값은 어노테이션에 적용하지 못해 불편하다ㅠㅠㅠ)

 

그러나 우리는(나 포함) 간단하기 때문에 쓴닼ㅋㅋㅋㅋㅋㅋㅋ. 다만 제품화가 되는 프로그램은 Java Enum 을 쓰는 것을 강력하게 권고 하고싶다(또는 완성도를 더 높이고 싶은 토이 프로젝트).

 

Enum은 다음의 특징이 존재한다.

  • Enum은 static final 하다.
  • 계승(상속)이 불가능하다. 
  • 컴파일 타임, 타입 세이프하다.
  • 그룹핑이 가능하다(Enum의 Enum을 하여 조합 가능).
  • Object를 계상받아 Object에서 제공하는 메소드를 활용할 수 있다. 또는 디폴트 메소드를 사용할 수 있다.
  • serializable, comparable이 가능하다.
  • 메소드를 사용하여 기능 확장이 무궁무진하다(상수 + 관련 데이터의 연계 및 연산을 사용할 수 있다.).
  • 비교 연산은 Int 상수와 성능이 비슷하다.

위와 같은 이유로 인해 코드 가독성이 좋으며, 형 안전성이 좋고, 기능이 강력하다.

Enum 기본

전형적으로 Enum을 사용하는 것은 다음과 같다.

public enum UserAuthority {
	GREAT_USER( "USR_001" ),
	GOOD_USER( "USR_002" ),
	COMMON_USER( "USR_003" ),
	BAD_USER( "USR_004" ),
	GET_OUT_USER( "USR_005" );
	
	private String authCode;
	
	UserAuthority( String authCode ) {
		this.authCode = authCode;
	}
    
	public String getCode() {
		return this.authCode;
	}		
}

1. Enum은 DB의 코드 값이랑 많이 활용할 수 있다. 추가적으로 코드를 추가하는데도 무리가 없다.

2. 실질적으로 사용할 때 UserAuthority.GREAT_USER 이런식으로 타입 세이프하게 사용할 수 있다.

3. 접근 사용자가 권한의 가능 여부를 판단하는 기능을 메소드 추가를 통해 해소할 수 있다(상수 관련 연계 및 연산 처리).

4. Enum의 Enum을 사용하여, 권한의 권한으로 GREAT_USER, GOOD_USER에 해당하는 권한만 존재하는 Enum을 생성할 수 있다.

 

Enum 활용

public enum Planet {
    MERCURY(3.302e+23, 2.439e6),
    VENUS (4.869e+24, 6.052e6),
    EARTH (5.975e+24, 6.378e6),
    MARS (6.419e+23, 3.393e6),
    JUPITER(1.899e+27, 7.149e7),
    SATURN (5.685e+26, 6.027e7),
    URANUS (8.683e+25, 2.556e7),
    NEPTUNE(1.024e+26, 2.477e7);
    
    private final double mass; // In kilograms
    private final double radius; // In meters
    private final double surfaceGravity; // In m / s^2

    // Universal gravitational constant in m^3 / kg s^2
    private static final double G = 6.67300E-11;
   
   	// Constructor
    Planet(double mass, double radius) {
   		 this.mass = mass;
   		 this.radius = radius;
   		 surfaceGravity = G * mass / (radius * radius);
    }
    
    public double mass() { return mass; }
    public double radius() { return radius; }
    public double surfaceGravity() { return surfaceGravity; }
   
   public double surfaceWeight(double mass) {
		return mass * surfaceGravity; // F = ma
   }
}

해당 코드는 이펙티브 자바의 Enum 편에 제일 첫장에 나오는 소스 코드이다. 

위와 같이 Enum을 활용할 수 있다. 

  • Planet.VENUS.mass() 또는 Planet.VENUS.radius() 를 통해 쉽게 상수 값을 확인할 수 있다.
  • 코드를 보면 딱 이해할 수 있다(가독성 원탑).
  • 해당 상수와 연계된 연산(surfaceWeight, surfaceGravity)을 해당 Enum에서 충분히 소화할 수 있다(SRP)

Enum은 EnumMap, EnumSet 활용하여 더 활용할 수 있으며, 인터페이스를 통해 유연하게 개발할 수 있다.

자세한 사항은 이펙티브 자바 Enum편 또는 Enum 활용에 대한 구글링을 참고한다. 정말 다양하게 Enum을 활용한다. 

멋지다.

 

Spring/MyBatis에서 Enum을 사용할 때 골 때리는 구간이 좀 존재한다. 아래와 같이 해결한다.

Spring에서의 Enum활용(Message Converter)

스프링에서 Jackson Mapper를 통해 JSON 메시지에 대해 자동 Message Converting 기능을 사용할 때 Enum은 별도 처리가 필요하다. 아래와 같이 @JsonValue어노테이션을 지정하면 된다. 좀 아쉬운 점은 모든 Message Converter에 공통적으로 적용하기는 어려운 듯 하다.. 그래도 어노테이션 하나 지정을 통해 개선할 수 있어 다행이다.

@JsonValue
public String getCode() {
	return this.authCode;
}

Spring에서의 Enum활용(Validation)

스프링에서는 Message Converting 작업과 동시에 Validation을 할 수 있게해준다. 그러면, Enum의 경우, 해당 작업을 어떻게 처리할까? @NotEmpty를 적용했을 경우 아래와 같은 오류를 뱉어 낸다.

Jackson mapper @NotEmpty 적용(실패)

{"status":"fail","errMsg":"UnexpectedTypeException: No validator could be found for type: **.EnumTestVo$UserAuthority","errCode":1}

별도의 처리를 하지 않을 경우, @NotNull @Null 만 적용할 수 있다. @NotNull을 처리할 경우, 아래와 같이 성공적인 벨리데이션 체크를 할 수 있다. 

Jackson mapper @NotNull 적용(성공)

{"status":"fail","errMsg":"CustomIllegalArgumentException: Message Validation Error : [target: enumTestVo.userAuthority, errMsg: may not be null ] ","errCode":21013}

만약 NotEmpty와 그 이상의 Validation은 그냥 Custom Validation을 생성하면 된다. 

 

EnumNamePattern.class

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = EnumNamePatternValidator.class)
public @interface EnumNamePattern {
	String[] enumCodes();
	String message() default "Enum value is invalid.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

 

EnumNamePatternValidator.class

public class EnumNamePatternValidator implements ConstraintValidator<EnumNamePattern, Enum<?>> {

	private Pattern pattern;
	 
	@Override
	public void initialize(EnumNamePattern annotation) {
		try {
			StringBuilder sb = new StringBuilder();
    		 
			sb.append( annotation.enumCodes()[0] );
			for( int i=1, len = annotation.enumCodes().length; i<len; i++ ) {
				sb.append( "|" ).append( annotation.enumCodes()[i] );
			}
			pattern = Pattern.compile( sb.toString() );
             
		} catch ( PatternSyntaxException e ) {
			throw new IllegalArgumentException("Given regex is invalid", e);
		}
	}
 
	@Override
	public boolean isValid(Enum<?> value, ConstraintValidatorContext context) {
		if (value == null) {
			return false;
		}
 
		Matcher m = pattern.matcher(value.name());
		return m.matches();
	}
}

 

VO객체 일부 발췌.class

@EnumNamePattern(enumCodes = {"BAD_USER"})
private UserAuthority userAuthority;

간략하게 설명하자면 다음과 같다.

1. @EnumNamePattern(enumCodes={"BAD_USER"})의 경우, BAD_USER만 받는 VO 객체이다. 

2. 해당 사항은 Custom Validation과 어노테이션을 추가한 것이다.

3. EnumNamePatternValidator에서 정규 표현식을 통해 참 / 거짓을 판단한다.

4. EnumNamePatternValidator에서 null 값에 대해 는 거짓으로 판단한다. 

initialize 메소드에서 최대한 작업할 수 있는 것을 작업하자, isValid 에서 매 Request마다 작업을 진행하기 때문이다. 

 

MyBatis에서의 Enum활용(TypeHandler)

이제 Controller 생성(아래 참고) 및 MyBatis를 연동한다. 우리는 "BAD_USER"가 아닌 DB 코드 값 "USR_004"를 입력하고 싶다. Typehandler를 적용하지 않을 경우, BAD_USER가 입력되는 불상사가 생긴다. MyBatis TypeHandler는 JDBC 프로그래밍에서의 ResultSet, PreparedStatement를 원하는대로 핸들링할 수 있다.

 

TypeHandler 미적용 감사로그(BAD_USER가 insert 된다)

[***]-2020-07-20 22:35:21.741 main DEBUG: **.mapper.enum.insertEnumTest - ==>  Preparing: insert into enum_test( user_id, user_name, user_authority ) values ( ?, ?, ? ); 
[***]-2020-07-20 22:35:21.764 main DEBUG: **.insertEnumTest - ==> Parameters: ehdvudee(String), shin(String), BAD_USER(String)
[***]-2020-07-20 22:35:21.770 main DEBUG: **.enum.insertEnumTest - <==    Updates: 1
[***]-2020-07-20 22:35:21.773 main DEBUG: **.mapper.enum.selectEnumTest - ==>  Preparing: select user_id userId, user_name userName, user_authority userAuthority from enum_test 
[***]-2020-07-20 22:35:21.773 main DEBUG: **.mapper.enum.selectEnumTest - ==> Parameters: 
[***]-2020-07-20 22:35:21.800 main TRACE: **.mapper.enum.selectEnumTest - <==    Columns: userid, username, userauthority
[***]-2020-07-20 22:35:21.800 main TRACE: **.mapper.enum.selectEnumTest - <==        Row: ehdvudee, shin, BAD_USER
[***]-2020-07-20 22:35:21.803 main DEBUG: **.mapper.enum.selectEnumTest - <==      Total: 1

 

VO객체 일부 발췌.class

@MappedTypes(UserAuthority.class)
public static class TypeHandler extends CodeEnumTypeHandler<UserAuthority> {
	public TypeHandler() {
		super( UserAuthority.class );
	}
}

위의 코드와 같이 @MappedTypes를 지정한다. 솔직히 원리는 잘 모르겠다(원리에 대해 학습이 필요). 

아래와 같이 TypeHandler 생성 및 MyBatis 설정에 TypeHandler를 추가한다. 

 

CodeEnumTypeHandler.class

public abstract class CodeEnumTypeHandler<E extends Enum<E>> implements TypeHandler<EnumTestVo.CodeEnum> {
	private Class<E> type;

	public CodeEnumTypeHandler( Class <E> type ) {
		this.type = type;
	}

	@Override
	public void setParameter( PreparedStatement ps, int i, EnumTestVo.CodeEnum parameter, JdbcType jdbcType ) throws SQLException {
		ps.setString( i, parameter.getCode() );
	}

	@Override
	public EnumTestVo.CodeEnum getResult( ResultSet rs, String columnName ) throws SQLException {
		String code = rs.getString( columnName );
		return getCodeEnum( code );
	}

	@Override
	public EnumTestVo.CodeEnum getResult( ResultSet rs, int columnIndex ) throws SQLException {
		String code = rs.getString( columnIndex );
		return getCodeEnum( code );
	}

	@Override
	public EnumTestVo.CodeEnum getResult( CallableStatement cs, int columnIndex ) throws SQLException {
		String code = cs.getString( columnIndex );
		return getCodeEnum( code );
	}

	private EnumTestVo.CodeEnum getCodeEnum( String code ) {
		try {
			EnumTestVo.CodeEnum[] enumConstants = (EnumTestVo.CodeEnum[]) type.getEnumConstants();
			for ( EnumTestVo.CodeEnum codeNum: enumConstants ) {           
				if ( codeNum.getCode().equals( code ) ) {
					return codeNum;
				}
			}
			
			return null;
		} catch (Exception e) {
			throw new TypeException("Can't make enum object '" + type + "'", e);
		}
	}
}

 

TypeHandler 적용(AppConfig.class)

private SqlSessionFactoryBean setCommonMybatisConfig( SqlSessionFactoryBean sessionFactory ) {
	sessionFactory.setTypeAliases( new Class<?>[] { 
		EnumTestVo.class
	});
	
	sessionFactory.setTypeHandlers( new TypeHandler[] {
		new EnumTestVo.UserAuthority.TypeHandler()
	});
	
	org.apache.ibatis.session.Configuration settings = new org.apache.ibatis.session.Configuration();
	
	settings.setCacheEnabled( false );
	settings.setJdbcTypeForNull( JdbcType.NULL );
	
	sessionFactory.setConfiguration( settings );
	
	return sessionFactory;
}

 

TypeHandler 적용 후("BAD_USER"가 아닌 "USR_004"가 들어가는 것을 확인할 수 있다.)

[***]-2020-07-20 22:40:01.195 main DEBUG: **.mapper.enum.insertEnumTest - ==>  Preparing: insert into enum_test( user_id, user_name, user_authority ) values ( ?, ?, ? ); 
[***]-2020-07-20 22:40:01.236 main DEBUG: **.mapper.enum.insertEnumTest - ==> Parameters: ehdvudee(String), shin(String), USR_004(String)
[***]-2020-07-20 22:40:01.257 main DEBUG: **.mapper.enum.insertEnumTest - <==    Updates: 1
[***]-2020-07-20 22:40:01.260 main DEBUG: **.mapper.enum.selectEnumTest - ==>  Preparing: select user_id userId, user_name userName, user_authority userAuthority from enum_test 
[***]-2020-07-20 22:40:01.261 main DEBUG: **.mapper.enum.selectEnumTest - ==> Parameters: 
[***]-2020-07-20 22:40:01.291 main TRACE: **.mapper.enum.selectEnumTest - <==    Columns: userid, username, userauthority
[***]-2020-07-20 22:40:01.292 main TRACE: **.mapper.enum.selectEnumTest - <==        Row: ehdvudee, shin, USR_004
[***]-2020-07-20 22:40:01.294 main DEBUG: **.mapper.enum.selectEnumTest - <==      Total: 1

 

최종 VO 객체

public class EnumTestVo {

	@NotEmpty
	private String userId;
	@NotEmpty
	private String userName;
	@EnumNamePattern(enumCodes = {"BAD_USER"})
	private UserAuthority userAuthority;
	
	public String getUserId() {
		return userId;
	}

	public void setUserId(String userId) {
		this.userId = userId;
	}

	public String getUserName() {
		return userName;
	}

	public void setUserName(String userName) {
		this.userName = userName;
	}

	public UserAuthority getUserAuthority() {
		return userAuthority;
	}

	public void setUserAuthority(UserAuthority userAuthority) {
		this.userAuthority = userAuthority;
	}

	@Override
	public String toString() {
		return "EnumTestVo [userId=" + userId + ", userName=" + userName + ", userAuthority=" + userAuthority + "]";
	}
	
	public interface CodeEnum {
		public String getCode();
	}

	public enum UserAuthority implements CodeEnum {
		GREAT_USER( "USR_001" ),
		GOOD_USER( "USR_002" ),
		COMMON_USER( "USR_003" ),
		BAD_USER( "USR_004" ),
		GET_OUT_USER( "USR_005" );
		
		private String authCode;
		
		UserAuthority( String authCode ) {
			this.authCode = authCode;
		}

		@Override
		@JsonValue
		public String getCode() {
			return this.authCode;
		}
		
		@MappedTypes(UserAuthority.class)
		public static class TypeHandler extends CodeEnumTypeHandler<UserAuthority> {
			public TypeHandler() {
				super( UserAuthority.class );
			}
		}
	}
}

 

 

Spring/MyBatis 환경에서 Enum을 활용하기 위해서는 아래의 여건들이 충족되어야 한다.

  • MessageConverter의 Enum 처리 
  • Validation 체크
  • MyBatis에서 처리하기 위해 TypeHandler 지정

해당 사항 테스트 코드

해당사항을 테스트 하기 위해, Controller 생성 및 Spring 테스트 코드를 작성하였다. 

 

HomeController.class

@RestController
@RequestMapping("/")
public class HomeControlller {
		
	private final CommonErrorHandler commonErrorHandler;
	private final SqlSession session;
	
	@Autowired
	public HomeControlller( CommonErrorHandler commonErrorHandler, SqlSession session ) {
		this.commonErrorHandler = commonErrorHandler;
		this.session = session;
	}
		
	@RequestMapping(value="user", method=RequestMethod.POST)
	public ResponseEntity<?> createUser( @RequestBody @Valid EnumTestVo enumTestVo, BindingResult bindingResult, WebRequest request ) {
		if ( bindingResult.hasErrors() ) {
			String errMsg = commonErrorHandler.getValidationErrMsg( bindingResult );
			throw new CustomIllegalArgumentException( ErrorCode.INVALID_HTTP_BODY, errMsg );
		}
		System.out.println( enumTestVo );
		
		// SERVICE & DAO AREA 
		
		session.insert("**.mapper.enum.insertEnumTest", enumTestVo );
		EnumTestVo returnVo = session.selectOne( "**.mapper.enum.selectEnumTest", enumTestVo );
		
		// SERVICE & DAO AREA
		
		return ResponseEntity.ok( returnVo );
	}
}

 

EnumTest.class

 

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration(classes= {WebConfig.class, AppConfig.class})
@Transactional
public class EnumTest {

	@Autowired
	private WebApplicationContext context;
	
	private MockMvc mockMvc;
	private ObjectMapper objectMapper; 
	
	@Before
	public void init() {
		this.mockMvc = MockMvcBuilders.webAppContextSetup( context ).build();
		this.objectMapper = new ObjectMapper();
		
		objectMapper.setSerializationInclusion( JsonInclude.Include.NON_NULL );
        objectMapper.setDateFormat( new SimpleDateFormat( "yyyy-MM-dd HH:mm:ss") );
	}
	
	@Test
	public void run() throws Exception {
		// GIVEN
		
		EnumTestVo vo = new EnumTestVo();
		
		vo.setUserId( "ehdvudee" );
		vo.setUserName( "shin" );
		vo.setUserAuthority( EnumTestVo.UserAuthority.BAD_USER );
		
		String jsonData = objectMapper.writeValueAsString( vo );
		
		System.out.println( "[Request JSON Body : " + jsonData + " ]");
		
		// WHEN THEN
		MvcResult result = mockMvc.perform( post( "/user" )
				.header( HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE )
				.header( HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE )
                .header( HttpHeaders.CONTENT_ENCODING, "UTF-8" )
                .content( jsonData )
                
		)
		.andDo( print() )
		.andExpect( status().isOk() )
		.andReturn();
		
		System.out.println( "[Response Body : " + result.getResponse().getContentAsString() + " ]"); 
	}

}

 

최종 로그

[Request JSON Body : {"userId":"ehdvudee","userName":"shin","userAuthority":"USR_004"} ]

[***]-2020-07-20 22:40:01.195 main DEBUG: **.mapper.enum.insertEnumTest - ==>  Preparing: insert into enum_test( user_id, user_name, user_authority ) values ( ?, ?, ? ); 
[***]-2020-07-20 22:40:01.236 main DEBUG: **.mapper.enum.insertEnumTest - ==> Parameters: ehdvudee(String), shin(String), USR_004(String)
[***]-2020-07-20 22:40:01.257 main DEBUG: **.mapper.enum.insertEnumTest - <==    Updates: 1
[***]-2020-07-20 22:40:01.260 main DEBUG: **.mapper.enum.selectEnumTest - ==>  Preparing: select user_id userId, user_name userName, user_authority userAuthority from enum_test 
[***]-2020-07-20 22:40:01.261 main DEBUG: **.mapper.enum.selectEnumTest - ==> Parameters: 
[***]-2020-07-20 22:40:01.291 main TRACE: **.mapper.enum.selectEnumTest - <==    Columns: userid, username, userauthority
[***]-2020-07-20 22:40:01.292 main TRACE: **.mapper.enum.selectEnumTest - <==        Row: ehdvudee, shin, USR_004
[***]-2020-07-20 22:40:01.294 main DEBUG: **.mapper.enum.selectEnumTest - <==      Total: 1

MockHttpServletRequest:
         HTTP Method = POST
         Request URI = /user
          Parameters = {}
             Headers = {Content-Type=[application/json], Accept=[application/json], Content-Encoding=[UTF-8]}

             Handler:
                Type = **.HomeControlller
              Method = public org.springframework.http.ResponseEntity<?> **.HomeControlller.createUser(**.EnumTestVo,org.springframework.validation.BindingResult,org.springframework.web.context.request.WebRequest)

  Resolved Exception:
                Type = null

        ModelAndView:
           View name = null
                View = null
               Model = null

            FlashMap:

MockHttpServletResponse:
              Status = 200
       Error message = null
             Headers = {Content-Type=[application/json;charset=UTF-8]}
        Content type = application/json;charset=UTF-8
                Body = {"userId":"ehdvudee","userName":"shin","userAuthority":"USR_004"}
       Forwarded URL = null
      Redirected URL = null
             Cookies = []

[Response Body : {"userId":"ehdvudee","userName":"shin","userAuthority":"USR_004"} ]

 

참고

https://github.com/HomoEfficio/dev-tips/wiki/SpringMVC%EC%97%90%EC%84%9C-Collection%EC%9D%98-Validation

https://www.holaxprogramming.com/2015/11/12/spring-boot-mybatis-typehandler/

https://www.baeldung.com/javax-validations-enums

https://woowabros.github.io/tools/2017/07/10/java-enum-uses.html

https://androphil.tistory.com/707

https://www.baeldung.com/javax-validations-enums

https://sejoung.github.io/2019/06/2019-06-05-spring_jackson-enum-serializing-and-deserializer/#%EC%8A%A4%ED%94%84%EB%A7%81-jackson-enum-deserializer