데이터베이스 설계를 하다 보면 복합키(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 |
'Spring Boot' 카테고리의 다른 글
[튜토리얼] IntelliJ로 CRUD 구현하기 (0) | 2022.08.08 |
---|---|
[튜토리얼] IntelliJ로 Hello World 찍기 (0) | 2022.08.08 |
[Jackson] 순환참조 해결방법 (0) | 2022.08.05 |
[JPA] 빈 문자열을 null로 저장하는 converter 만들기 (0) | 2022.08.04 |
Hibernate 부모-자식 연관관계 삭제 문제 (0) | 2019.03.05 |