본문 바로가기
Lecture

객체 필드 중복을 해결하는 다양한 방법들과 객체지향 패러다임을 이용한 순수 코드 방식의 해결법(feat. DDD의 Aggregate와의 연관성)

by Renechoi 2023. 7. 22.

문제 정의 

 

비슷한 역할을 하는 객체가 여러 있을 수 있다. 이때 동일한 필드를 가져 중복 코드가 다수 발생하는 문제가 생긴다. 

 

예를 들어 일정 관리 프로그램을 만드는 과정에서 다음과 같이 Event(이벤트), Task(할일), Notification(알림) 객체들이 있다고 해보자. 

 

@Getter
@NoArgsConstructor
public class Event {

    private Long id;
    private LocalDateTime startAt;
    private LocalDateTime endAt;
    private String title;
    private String description;
    private User writer;
    private List<Engagement> engagements;
    private LocalDateTime createdAt;

    public Event(Long id, LocalDateTime startAt, LocalDateTime endAt, String title,
                 String description, User writer,
                 List<Engagement> engagements, LocalDateTime createdAt) {
        this.id = id;
        this.startAt = startAt;
        this.endAt = endAt;
        this.title = title;
        this.description = description;
        this.writer = writer;
        this.engagements = engagements;
        this.createdAt = createdAt;
    }

    public void addEngagement(Engagement engagement) {
        if (this.getEngagements() == null) {
            this.engagements = new ArrayList<>();
        }
        this.engagements.add(engagement);
    }
}

 

@Getter
@NoArgsConstructor
public class Notification {

    private Long id;
    private LocalDateTime notifyAt;
    private String title;
    private String description;
    private User writer;
    private LocalDateTime createdAt;
}

 

@Getter
@NoArgsConstructor
public class Task {

    private Long id;
    private LocalDateTime taskAt;
    private String title;
    private String description;
    private User writer;
    private LocalDateTime createdAt;
}

 

이 코드에서 Event, Notification, Task 클래스는 각각 다른 비즈니스 로직을 수행하지만, 일부 필드가 중복되어 있다.

 

id외 공통으로 가지는 필드는 다음과 같다:

- createdAt: 객체가 생성된 시간을 저장
- title, description, writer: 일정이나 알림의 제목, 설명, 작성자를 나타냄. 

 

 

다양한 해결법들 

 

1. 상속을 이용한 해결: 공통 속성을 부모 클래스로 정의 

 

당연히 가장 먼저 생각해볼 수 있는 방법이 될 것이다. 

 

즉, 이벤트 테이블을 확장하되, 부모 클래스로부터 공통 속성을 받는 것이다. 이와 같은 방식은 상속 관계를 매핑하는 전략으로서 JPA를 사용하는 프로젝트에서 수월하게 다음과 같이 구현할 수 있다. 

 

먼저 ScheduleItem이라는 이름의 상위 클래스를 만든다. 

 

@MappedSuperclass
@Getter
@NoArgsConstructor
public class ScheduleItem {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String title;
    private String description;
    private User writer;
    private LocalDateTime createdAt;

    public ScheduleItem(Long id, String title, String description, User writer, LocalDateTime createdAt) {
        this.id = id;
        this.title = title;
        this.description = description;
        this.writer = writer;
        this.createdAt = createdAt;
    }
}

 

 

 

 

 

@MappedSuperclass 애노테이션을 사용해 해당 필드들을 상속 방식으로 매핑해준다. 

 

 

@Entity
@Table(name = "events")
@Getter
@NoArgsConstructor
public class Event extends ScheduleItem {

    private LocalDateTime startAt;
    private LocalDateTime endAt;
    private List<Engagement> engagements;

    public Event(Long id, LocalDateTime startAt, LocalDateTime endAt, String title,
                 String description, User writer,
                 List<Engagement> engagements, LocalDateTime createdAt) {
        super(id, title, description, writer, createdAt);
        this.startAt = startAt;
        this.endAt = endAt;
        this.engagements = engagements;
    }

    public void addEngagement(Engagement engagement) {
        if (this.engagements == null) {
            this.engagements = new ArrayList<>();
        }
        this.engagements.add(engagement);
    }
}

 

 

@Entity
@Table(name = "notifications")
@Getter
@NoArgsConstructor
public class Notification extends ScheduleItem {

    private LocalDateTime notifyAt;

    public Notification(Long id, LocalDateTime notifyAt, LocalDateTime createdAt, String title, String description, User writer) {
        super(id, title, description, writer, createdAt);
        this.notifyAt = notifyAt;
    }
}

 

@Entity
@Table(name = "tasks")
@Getter
@NoArgsConstructor
public class Task extends ScheduleItem {

    private LocalDateTime taskAt;

    public Task(Long id, LocalDateTime taskAt, LocalDateTime createdAt, String title, String description, User writer) {
        super(id, title, description, writer, createdAt);
        this.taskAt = taskAt;
    }
}

 

Event, Notification, Task 클래스는 각각 ScheduleItem 클래스를 상속받아 공통 필드를 사용하며, 각각의 특성에 맞는 추가 필드를 정의한다. 이렇게 구현하면 공통 필드에 대한 중복을 제거하고, 각 클래스의 특성에 맞는 필드와 메소드를 구현할 수 있다.

 

어쩌면 가장 일반적으로 생각해볼 수 있고 사용되는 방식일 것이다.

 

한편, 편리하고 직관적이라는 장점이 있지만 JPA 의존성이 높아진다는 단점이 있다. 

 

이러한 단점은 당장 프로젝트를 구현해내는 데에 있어서는 큰 문제가 되는 단점은 아닐 것이다. 하지만 JPA가 아닌 프로젝트라면 어떨까? 테이블 매핑을 구현하기가 복잡해질 수 있다. ORM 방식으로서 JPA는 현재 표준으로 자리잡고 있는 것은 사실이지만 영원하리란 법은 없다. 그렇다면 다른 데이터베이스 접근 로직을 가진 프레임워크로 변경사항을 염두에 둔다고 할 때 이와 같은 단점은 크게 작용할 수 있음을 생각해볼 수도 있겠다. 

 

 

 

 

 

2. 하나의 타입 속성으로서 추가하기 

 

두 번째 방식은 각 엔티티의 타입을 나타내는 별도의 속성을 추가하는 것을 말한다. 예를 들어 다음과 같다.

 

 

@Entity
@Table(name = "schedule_items")
@Getter
@NoArgsConstructor
public class ScheduleItem {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String title;
    private String description;

    @ManyToOne
    @JoinColumn(name = "writer_id")
    private User writer;

    private LocalDateTime createdAt;

    @Enumerated(EnumType.STRING)
    private ScheduleType scheduleType; 

    private LocalDateTime eventAt;  

    @OneToOne
    private Event event;  

    @OneToOne
    private User attendee;  

    @Enumerated(EnumType.STRING)
    private RequestStatus status;  

    public ScheduleItem(Event event, User attendee, RequestStatus status, String title, String description, User writer, LocalDateTime createdAt) {
        this.event = event;
        this.attendee = attendee;
        this.status = status;
        this.title = title;
        this.description = description;
        this.writer = writer;
        this.createdAt = createdAt;
        this.scheduleType = ScheduleType.EVENT;
    }


    public ScheduleItem(LocalDateTime notifyAt, String title, String description, User writer, LocalDateTime createdAt) {
        this.eventAt = notifyAt;
        this.title = title;
        this.description = description;
        this.writer = writer;
        this.createdAt = createdAt;
        this.scheduleType = ScheduleType.NOTIFICATION;
    }


    public ScheduleItem(LocalDateTime taskAt, String title, String description, User writer, LocalDateTime createdAt) {
        this.eventAt = taskAt;
        this.title = title;
        this.description = description;
        this.writer = writer;
        this.createdAt = createdAt;
        this.scheduleType = ScheduleType.TASK;
    }
}

 

이처럼 중복되는 필드를 모두 포함하는 새로운 엔티티를 생성한다. 추가로 scheduleType 필드를 도입하여 해당 객체가 어떤 타입인지를 구분한다. notifyAt와 taskAt는 도메인 용어가 달라지지만 실제로는 같은 의미를 가진 필드이므로 eventAt 하나로 통합해야 한다는 정보 유실이 발생한다. 

어쨌든 이렇게 하면 모든 객체를 하나의 테이블에 저장할 수 있다. 하지만 다음과 같은 단점을 생각해볼 수 있다. 

 

첫째, Engagement만 사용하는 event, attendee, status 필드는 Notification과 Task에서는 항상 null이 된다. 즉 null value가 계속해서 데이터베이스에 쌓여야 한다는 것이다. 이는 공간 효율성에서 큰 감점요인이 아닐 수 없다. 둘째, 어떤 필드가 어떤 타입에서 사용되는지를 코드만 보고 알기 어려워 가독성 저해의 요인이 된다.  셋째, 로직을 처리할 때마다 타입 체크를 해야 하는 번거로움이 있어 효율적인 코드 작성이 어렵다. 

 

 

3. 순수하게 자바 코드로 해결해보자 

 

세 번째 방식은 자바 코드로 풀이하는 방식이다. 객체지향적인 구현을 생각해보자.

어쨌든 목적이 '공통된 코드의 중복을 줄이고, 각각의 객체가 자신의 역할에 집중하도록 만드는 것'이라면, '각각의 객체에 필요한 필드와 메소드만을 정의하고, 공통된 부분은 별도의 객체를 통해 관리하는 방식'을 사용하는 것이 가능하지 않을까? 각각의 객체는 자신의 책임과 역할에만 집중할 수 있게 되고, 중복된 코드는 최소화하는 목적을 달성하면서도 효율적으로 비즈니스 로직을 구현할 수 있을 것이다. 

 

예를 들어 다음과 같다. 

 

스케줄 엔티티를 다음과 같이 정의한다. 

 

@Builder(access = AccessLevel.PRIVATE)
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "schedules")
public class Schedule extends BaseEntity {

    private LocalDateTime startAt;
    private LocalDateTime endAt;
    private String title;
    private String description;

    @JoinColumn(name = "writer_id")
    @ManyToOne
    private User writer;

    @Enumerated(EnumType.STRING)
    private ScheduleType scheduleType;

    public static Schedule event(String title,
                                 String description, LocalDateTime startAt,
                                 LocalDateTime endAt, User writer) {
        return Schedule.builder()
                       .startAt(startAt)
                       .endAt(endAt)
                       .title(title)
                       .description(description)
                       .writer(writer)
                       .scheduleType(ScheduleType.EVENT)
                       .build();
    }

    public static Schedule task(String title, String description, LocalDateTime taskAt,
                                User writer) {
        return Schedule.builder()
                       .startAt(taskAt)
                       .title(title)
                       .description(description)
                       .writer(writer)
                       .scheduleType(ScheduleType.TASK)
                       .build();
    }

    public static Schedule notification(String title, LocalDateTime notifyAt, User writer) {
        return Schedule.builder()
                       .startAt(notifyAt)
                       .title(title)
                       .writer(writer)
                       .scheduleType(ScheduleType.NOTIFICATION)
                       .build();
    }

    public Task toTask() {
        return new Task(this);
    }

    public Event toEvent() {
        return new Event(this);
    }

    public Notification toNotification() {
        return new Notification(this);
    }
}

 

이제 Event, Notification, Task를 다음과 같이 정의할 수 있다. 

 

public class Event {
    private Schedule schedule;

    public Event(Schedule schedule) {
        this.schedule = schedule;
    }

    public User getWriter() {
        return this.schedule.getWriter();
    }
}

 

 

public class Notification {

    private Schedule schedule;

    public Notification(Schedule schedule) {
        this.schedule = schedule;
    }

    public User getWriter() {
        return this.schedule.getWriter();
    }
}

 

public class Task {

    private Schedule schedule;

    public Task (Schedule schedule) {
        this.schedule = schedule;
    }

    public User getWriter() {
        return this.schedule.getWriter();
    }
}

 

객체 지향 프로그래밍 패러다임에 따른 구현이라고 할 수 있다. Task, Event, Notification 등의 객체가 Schedule 객체를 통해 생성되며, 이를 통해 다양한 스케줄 타입을 표현한다.

 

호출되어 사용되는 방식은 다음과 같다. 

 

final Schedule taskSchedule = Schedule.task("title", "desc", now, user1);
		final Task task = taskSchedule.toTask();
		assertEquals("user1",
			task.getWriter()
				.getName());

		final Schedule notiSchedule = Schedule.notification("title", now, user1);
		final Notification notification = notiSchedule.toNotification();
		assertEquals("user1",
			notification.getWriter()
				.getName());

		final Schedule eventSchedule = Schedule.event("title", "desc", now, now, user1);
		final Event event = eventSchedule.toEvent();
		assertEquals("user1",
			event.getWriter()
				.getName());

 

 

Schedule 테이블을 기반으로 하는 여러 개의 도메인 객체 (Task, Event, Notification)를 구현할 수 있으므로, 데이터 중복을 방지하고 테이블의 구조를 단순화하는 목적을 달성한다. 동시에, 비즈니스 로직을 각 도메인 객체에 캡슐화하는 데 사용한다면 유연하고 확장성 있는 코드 작성도 가능해진다. 

 

예를 들어,  Task는 할 일의 완료 여부를 확인하는 로직, Event는 이벤트의 참석자 목록을 관리하는 로직, Notification는 알림의 우선순위를 관리하는 로직 등을 가질 수 있다. 핵심은 해당 로직들이 각 도메인 객체 내부에 캡슐화되어, 각 객체의 메소드를 통해 접근되어 사용될 수 있다는 것이다. 

 

그렇다면 이 방식은 DDD 개발 방법론에서 이야기하는 aggregate 개념과 어떤 연관성이 있는가? 유사성과 차이점이 있다. 

 

먼저 유사점은 Event, Notification, Task 모두 Schedule 클래스를 통해 생성되며, Schedule 클래스는 Event, Notification, Task 클래스를 관리하는 역할을 한다는 것이다. 이런 구조는 DDD의 aggregate 개념에서 말하는 "하나의 aggregate는 하나의 root entity를 통해 다른 entity나 value object를 관리한다"는 개념과 일치한다. 이 맥락을 적용한다면 Schedule이 root entity 역할을 하며, Event, Notification, Task는 Schedule을 통해 관리되는 객체들이 될 것이다.

 

한편 차이점은 다음과 같다. DDD의 aggregate는 일관성 경계를 제공하는데, 이는 aggregate 내의 모든 객체가 함께 일관된 상태를 유지하도록 보장하는 역할을 한다. 또한 aggregate는 도메인의 일관성을 보장하기 위해 필요한 규칙을 캡슐화하고, 그 경계를 넘나드는 모든 행동에 대한 통제를 수행한다. 하지만 현재의 Schedule, Event, Notification, Task 클래스 구조는 이러한 일관성 경계를 제공하지는 않는다. 이들 클래스는 단순히 데이터의 중복을 방지하고 간결한 코드를 작성하기 위한 목적으로 구현되었지, 특정 비즈니스 규칙을 캡슐화하거나 일관성을 보장하는 로직은 포함하고 있지 않다는 점에서 DDD 방식의 aggregate 구현과는 차이점이 있다고 할 수 있다.


결론 

 

상속을 이용하는 방법은 가장 간단하고 직관적이지만, JPA 의존성이 높아지는 단점이 있다. 이 방법은 JPA를 사용하는 프로젝트에 가장 적합하며, 빠르게 구현할 수 있는 장점이 있다.

하나의 타입 속성을 추가하는 방법은 모든 객체를 하나의 테이블에 저장할 수 있지만, 필요없는 필드에 대해 NULL 값을 계속해서 저장해야 하고, 어떤 필드가 어떤 타입에서 사용되는지를 코드만 보고 알기 어렵다. 따라서 이 방법은 공간 효율성이나 가독성 측면에서 단점이 될 수 있다.

마지막으로 순수하게 자바 코드로 해결하는 방법은 객체 지향 프로그래밍의 원칙에 가장 부합하는 방법이 될 것이다. 각 객체가 자신의 역할에 집중하고, 공통된 부분은 별도의 객체를 통해 관리한다. 코드가 복잡해지는 단점이 있지만 확장성과 유연함을 제공하고 캡슐화에 있어서 유리함을 제공한다.

 

어떤 방법이 더 낫다기 보다는 프로젝트의 요구 사항과 환경에 따라 가장 적합한 방식이 무엇인지 고민해보면 좋을 것이다. 

 


 

참고 자료

- 패스트캠퍼스: 한 번에 끝내는 Spring 완.전.판 초격차 패키지 Online.

반응형