[아이디어] 복합키(Composite-key), 쉽게 CRUD 해보자
데이터베이스 설계를 하다 보면 복합키(Composite-key)를 활용해야 하는 경우를 피할 수 없을 때도 있다.
만약 우리가 사용자에게 CRUD를 제공하는 웹 서비스를 개발한다면 이 복합키를 어떻게 처리해야 할 지 분명 고민에 빠질 것이다.
이 포스트에서는 AS-IS와 TO-BE 예제를 통해 Converter를 이용해 복합키를 지닌 엔티티의 CRUD를 매우 간편하게 처리할 수 있는 아이디어를 다룬다.

복합키를 가진 엔티티 정의
우선 AS-IS와 TO-BE를 설명하기 전에 복합키를 가진 엔티티를 정의해 보자
우선 Room.class 라는 엔티티를 정의하였다. 이 Room은 학년-반으로 이루어진 복합키를 가진다.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | @Getter @Setter @Entity @Table(name = "room") @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(onlyExplicitlyIncluded = true) public class Room {   @Valid   @EmbeddedId   @EqualsAndHashCode.Include   private RoomId id = new RoomId();   @JsonIgnore   @Column(columnDefinition = "boolean default false", nullable = false, insertable = false)   private boolean removed = false;   public Room(RoomId id) {     this.id = id;   } } | cs | 
다음은 Room의 복합키 클래스인 RoomId.java 즉 ID클래스이다.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | @Getter @Setter @Embeddable @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(onlyExplicitlyIncluded = true) public class RoomId implements Serializable {   @NotNull   @EqualsAndHashCode.Include   @Column(name = "grade")   private Integer grade;   @NotBlank   @Size(max = 30)   @EqualsAndHashCode.Include   @Column(name = "name", columnDefinition = "varchar(30)")   private String name; } | cs | 
AS-IS
우리는 아래와 같이 강의실에 대한 CRUD를 제공하는 웹 서비스를 개발하고자 한다.
PK는 복합키로 이루어져있으며, 사용자는 화면에서 목록 조회, 추가, 수정, 삭제의 작업을 할 수 있다.
만약 단일키라면 /room/3 과 같은 형식의 PathVariable을 주어 해당 엔티티를 특정 할 수 있을 것이다. 하지만 복합키라면?
단순하게 생각하면 /room/1/1 처럼 키를 나열하면 되겠지 라고 생각할 수 있다. 하지만 아래 UI를 보면 알겠지만 다중 건에 대한 삭제가 가능하다. 즉, ID를 List로 받아 처리할 수 있어야 한다는 것

TO-BE
우리는 이 문제의 해결을 위해 직렬화-역직렬화 매커니즘을 이용해야 한다.
직렬화를 사용하게 되면 단일키를 사용하는 것과 같은 구조로 CRUD 구현이 가능해지는데 아래 예제를 통해 설명하도록 하겠다.
1. 직렬화
먼저 RoomId.java 코드의 변경이 필요하다.
주의깊게 볼 부분은 toString 메소드와 fromHash 메소드인데 toString은 직렬화, fromHash는 역직렬화를 위한 메소드이다.
(본 포스트를 읽는 시간을 절감하기 위해 코드를 매우 단순하게 작성하였습니다. 추후 이를 응용하여 클래스 상속 체계로 구축하면 매우 편리합니다.)
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | @Getter @Setter @Embeddable @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(onlyExplicitlyIncluded = true) public class RoomId implements Serializable {   @NotNull   @EqualsAndHashCode.Include   @Column(name = "grade")   private Integer grade;   @NotBlank   @Size(max = 30)   @EqualsAndHashCode.Include   @Column(name = "name", columnDefinition = "varchar(30)")   private String name;   public static RoomId fromHash(String hash) {     String[] fields = CipherUtil.decodeBase16(hash).split(Pattern.quote(Constant.HASH_SEPARATOR));     return new RoomId(Integer.parseInt(fields[0]), fields[1]);   }   @Override   public String toString() {     return CipherUtil.encodeBase16(String.format("%d%s%s", grade, Constant.HASH_SEPARATOR, name));   } } | cs | 
이렇게 toString 메소드를 구현하게 되면 View에서 자동으로 직렬화된 값을 이용하게 된다.
아래는 AS-IS에서 보았던 UI의 목록을 그려주는 코드로 r.id 즉 id필드를 출력하면 자동으로 직렬화된 값이 출력된다.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 | <tr th:each="r : ${roomList}">   <td scope="row">     <div class="checkbox checkbox-info">       <input th:id="|check${r.id}|" th:value="${r.id}" name="ids" type="checkbox">     </div>   </td>   <td>     <a th:href="|@{/room/edit/}${r.id}${queryString}|" th:text="${r.id.grade}"></a>   </td>   <td>     <a th:href="|@{/room/edit/}${r.id}${queryString}|" th:text="${r.id.name}"></a>   </td> </tr> | cs | 
1-1 의 복합키 필드는 직렬화를 통해 317C31 으로 변환됨을 알 수 있다.

2. 역직렬화
이제 직렬화를 했으니 역직렬화를 통해 클래스로 변환시킬 단계이다.
가장 먼저 Converter 클래스가 필요하다. 이 Converter는 직렬화된 값이 역직렬화될 대상 클래스로 변환되기 위해 필요한 로직을 정의하고 있다.
| 1 2 3 4 5 6 7 8 9 10 11 | @Component public class StringToRoomIdConverter implements Converter<String, RoomId> {   @Override   public RoomId convert(String s) {     if (StringUtils.isNotBlank(s)) {       return RoomId.fromHash(s);     }     return null;   } } | cs | 
이제 모든 준비는 끝났다. 아래는 컨트롤러에서 요청을 처리하는 코드이다.
| 1 2 3 4 5 6 7 8 |   @GetMapping({"/edit", "/edit/{id}"})   public String edit(@PathVariable(required = false) RoomId id, Model model, RoomFilter filter) {     Room room = (id == null) ? new Room()         : roomService.findById(id).orElseThrow(DataNotFoundException::new);     prepareModel(model, filter, room);     return "room/edit";   } | cs | 
여러 개의 목록을 삭제하는 경우에는?
별거 없다. 마찬가지로 List<RoomId> 로 받기만 하면 된다. 모든 일은 Converter가 알아서 한다.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |   @PostMapping("/delete")   public String delete(@RequestParam("ids") List<RoomId> ids, RoomFilter filter,       RedirectAttributes redirectAttr, HttpServletRequest request) {     filter.fromQueryString(request.getQueryString());     try {       long count = roomService.deleteAllByIdIn(ids);       redirectAttr.addFlashAttribute("type", Constant.TOAST_TYPE_SUCCESS);       redirectAttr.addFlashAttribute("message", String.format("%d개의 데이터가 삭제되었습니다.", count));     } catch (Exception e) {       redirectAttr.addFlashAttribute("type", Constant.TOAST_TYPE_ERROR);       redirectAttr.addFlashAttribute("message", "데이터 삭제에 실패하였습니다. 다시 시도해주세요.");     }     return "redirect:/room?" + filter.getQueryString();   } | cs | 
FAQ
1. 직렬화를 할 때 사용하는 CipherUtil의 코드가 궁금합니다.
| 1 2 3 4 5 6 7 8 9 10 11 | @UtilityClass public class CipherUtil {   public static String encodeBase16(String text) {     return BaseEncoding.base16().encode(text.getBytes(StandardCharsets.UTF_8));   }   public static String decodeBase16(String text) {     return new String(BaseEncoding.base16().decode(text));   } } | cs | 
2. 직렬화 코드를 일일히 작성하면 관리할 코드가 너무 많아집니다. 자동으로 처리할 수는 없을까요?
Java Reflection API를 이용해 특정 인스턴스의 필드에 접근할 수 있습니다. 이를 이용하면 직렬화-역직렬화를 자동화 할 수 있으며, super클래스 하나만 잘 만들어 놓으면 상속을 통해 아주 편리하게 개발을 할 수 있습니다.
커스텀 어노테이션까지 만들어 놓으면 금상첨화인 셈이죠.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |   public static String getHash(Object obj) {     List<Field> fieldList = Arrays.stream(obj.getClass().getDeclaredFields())         .collect(Collectors.toList());     List<String> idList = new ArrayList<>();     fieldList.forEach(f -> {       f.setAccessible(true);       try {         idList.add(f.get(obj).toString());       } catch (Exception ignore) {       }     });     return CipherUtil.encodeBase16(String.join(Constant.HASH_SEPARATOR, idList));   } | cs |