[우아한테크코스] 출석부

2025. 5. 29. 03:11·Java

이번 스터디에서는 우테코 출석부 미션을 진행했다.

기능 요구사항은 다음과 같다.

 

기능 요구 사항을 보았는데 어떻게 설계를 해야할지 감이 안잡혔다..

일단 나름대로 기능 목록을 정리해보았다.

 

기능 요구사항도 많고 다른 고려사항도 많아서 일단 구현만 되게 목표를 잡았다.

기존에 하던 mvc방식으로 설계를 하였다.

다음과 같이 구조를 잡았다.

 

이제 구현해보자.

중요하지 않은 코드와 이전에 진행했던 과제에서 중복되는 내용들은 생략하도록 하겠다. 전체 코드는 아래의 링크에 첨부되어있다.


model 패키지

AttendanceStatus클래스

enum으로 구현하였다.

출석, 지각, 결석 상태를 저장한다.

 

Attendance클래스

출석 기록 한 건을 나타내는 도메인 객체이다.

생성자를 정적 팩토리 메서드로 구현하였다.

  • 날짜를 저장한다.
  • 출석, 지각, 결석 중 상태를 저장한다. 상태를 저장하는 객체는 enum으로 구현하였다.

Crew

이름을 가진 크루 한 명을 표현하며, 그 사람의 모든 출석기록을 보관하는 객체이다.

  • 이름을 저장한다.
  • 크루 한 명의 모든 출석기록을 저장한다.

repository 패키지

crewRepository클래스

크루의 이름으로 해당 크루의 출석기록을 찾는다. 저장소 역할이라서 repository패키지를 새로 구현하였다.

Map형식으로 만들었다.

service 패키지

AttendanceFileLoaderService

CSV 파일로부터 출석 데이터를 읽고, 공휴일·주말을 제외한 정상 수업일에 대해 출석 상태를 판단한 후, 각 크루의 출석 기록을 저장소(CrewRepository)에 추가하는 역할을 한다.

미리 CSV파일이 주어졌고 주어진 크루들로만 출석 기능들을 사용하기 때문에 애플리케이션을 실행할 때 자동 저장되도록 구현하였다.

 

AttendanceStatusCalculatorService

출석 시간(attendTime)과 수업 시작 시간(classStartTime)**을 비교해서, 출석 상태를 판단해주는 유틸성 서비스로 구현했다.

 

AttendanceCheckService

출석 등록 기능을 담당하는 서비스 계층이다.

사용자(크루)가 입력한 출석 시간 문자열을 바탕으로 오늘 날짜의 출석을 등록하고, 출석 상태를 판별해서 저장하며, 이미 등록된 출석이 있는 경우 예외를 발생시킨다.

 

AttendanceModifyService

특정 날짜의 출석 정보를 수정하는 서비스 계층이다.

 

AttendanceCheckService

특정 크루의 이번 달 출석 상태를 분석해서 이번 달 출석 내역을 분석하고, 출석/지각/결석 개수 및 위험 등급을 계산한 뒤 DTO에 담아 반환하는 서비스이다.

 

AttendanceRiskCheckService

전체 크루들에 대해 출석 위험도를 분석하는 서비스이다. 기능 요구사항 기준에 맞춰 구현한다.

출석 위험 수준이 경고, 상담, 제적인 사람만 필터링해 그 결과를 DTO 리스트로 반환하는 서비스이다.

dto패키지

서비스에서 컨트롤러에 데이터를 전달하고 그 데이터를 뷰에 넘기는 과정에서 데이터를 한 객체에 담아서 이동시키는게 좋을 것이라 판단해 도입하였다. 직접 Crew와 출석 기록을 넘기기보다 필요한 정보만 가볍게 담고 전달한다.

외부 계층(View)과 내부 도메인 로직 간의 안정적이고 유연한 데이터 전달을 위해 도입하였다.

 

AttendanceCheckDto

한 크루의 출석 통계 + 위험 수준을 묶어서 담아주는 단순한 데이터 전달 객체이다.

 

RiskCheckDto

위험 수준이 경고 이상인 크루들만 리스트로 모아 넘기는 데이터 전달 객체이다.

 

뷰와 컨트롤러는 생략하겠다.


문제점

기능 구현할게 많고 어렵다보니 제대로 동작하도록 하는것에 집중했다. 그러다보니 도메인 모델의 역할에 대해 생각하지 않고 서비스를 많이 만드는 식으로 구현하였다. 

당연히 다른사람이 도메인(모델)의 코드를 보았을 때 비즈니스 개념이 전혀 없기 때문에 도메인에 대해 알 수 없을 것이다.

도메인 모델은 본래 "비즈니스 개념을 객체로 표현"하기 위한 핵심 계층이다. 지금의 코드는 DDD(Domain-Driven Design)이나 헥사고날 아키텍처 등의 기본 철학에서 벗어난다.

 

추가적인 피드백으로는 dto와 repository 부분이다.

dto는 로직을 갖지 않으며 데이터를 가지고 이동만 한다. 모델과 구조가 비슷해서 혼란을 줄 수 있다. 

repository는 보통 도메인 객체를 영속성 계층(DB, 파일, 메모리 등)에 저장하거나 불러오는 역할을 추상화한 객체로 생각하기 쉽다. 이런 부분에서 의미가 애매해졌다. 


리팩토링

서비스 중심 구조의 문제점을 돌아보았고 또한 피드백을 바탕으로 리팩토링을 해보았다.

도메인 주도 설계(Domain-Driven Design) 관점에서 정리하고 리팩토링을 진행하였다.

기존 코드의 문제점

  • 출석 등록, 수정, 분석 등 모든 로직이 서비스 클래스에 있음
  • Crew, Attendance 등 도메인 모델은 필드와 getter만 있음
  • 도메인은 상태만 있고, '행위'는 전혀 하지 않음
  • 서비스의 크기가 커지고, 테스트도 어렵고, 코드 흐름도 복잡함

리팩토링 방향

도메인에 책임을 되돌려주자.

도메인 객체에게 비즈니스 로직의 책임을 분산시키는 것이다.

dto는 없어도 될 듯 하다.

 

그렇다면 어떤 도메인이 어떤 책임을 가져야하는지 어떻게 판단할까? 서비스와의 차이는 무엇일까?

비즈니스 로직 중에서 해당 도메인에 종속적인 것들이 도메인의 책임이라고 기준을 잡았다. 

자신의 멤버변수(상태)만으로 처리할 수 있는 로직을 책임지게 하는 것이다.

서비스는 여러 도메인에 걸쳐있거나 외부 의존성을 가진 로직을 담당해야된다고 기준을 잡았다.

이러한 원칙을 가지고 리팩토링을 시작했다.

 

도메인 중심으로 구현하기 위해 repository를 보았다. 이 객체는 내부에 Map<String, List<Attendance>>로 저장하고 서비스가 이 저장소를 직접 다루며 출석을 등록하고 조회하고 수정하는 구조였다. 다음과 같은 문제점을 확인했다.

  • 출석을 등록할 때마다 중복 체크 로직이 반복
  • 데이터 구조인 Map이 서비스 코드에 드러남
  • 출석 기록에 대한 비즈니스 규칙은 서비스가 다 담당

개선하기 위해 출석부를 생각했다. 도메인 개념 자체인 크루들의 출석 기록을 다루는 책임 있는 객체를 만들었다.

이와 더불어 기존의 도메인들이 책임을 가지도록 구현하였다.

 

Attendance클래스

public class Attendance {
    private static final int ALLOWED_MINUTES_FOR_ATTENDANCE = 5;
    private static final int ALLOWED_MINUTES_FOR_LATE = 30;

    private final LocalDateTime dateTime;
    private final AttendanceStatus status;

    private Attendance(LocalDateTime dateTime, AttendanceStatus status) {
        this.dateTime = dateTime;
        this.status = status;
    }
    
    public static Attendance from(LocalDateTime dateTime, LocalTime classStartTime) {
        AttendanceStatus status = calculateStatus(dateTime, classStartTime);
        return new Attendance(dateTime, status);
    }

    private static AttendanceStatus calculateStatus(LocalDateTime attendTime, LocalTime classStartTime) {
        ...
    }
    
    public LocalDateTime getDateTime() {
        return dateTime;
    }

    public AttendanceStatus getStatus() {
        return status;
    }

}

자기 자신만의 정보로 상태를 결정할 수 있도록 구현했다.

 

Crew클래스

public class Crew {
    private final String name;
    private int attendCount = 0;
    private int lateCount = 0;
    private int absentCount = 0;
    private AttendanceRiskLevel riskLevel = AttendanceRiskLevel.NORMAL;

    private static final int LATE_PER_ABSENT = 3;
    private static final int WARNING_THRESHOLD = 2;
    private static final int COUNSEL_THRESHOLD = 3;
    private static final int EXPULSION_THRESHOLD = 5;


    private Crew(String name) {
        this.name = name;
    }

    public static Crew from(String name){
        return new Crew(name);
    }
    
    public void applyAttendanceStatus(AttendanceStatus status) {
        ...
    }

    public void revertAttendanceStatus(AttendanceStatus status) {
        ...
    }

    private void updateRiskLevel() {
        ...
    }
    
    public String getName(){
        return name;
    }

    public int getAttendCount() {
        return attendCount;
    }

    public int getLateCount() {
        return lateCount;
    }

    public int getAbsentCount() {
        return absentCount;
    }

    public AttendanceRiskLevel getRiskLevel() {
        return riskLevel;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Crew crew = (Crew) o;
        return Objects.equals(name, crew.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }
}

크루 본인이 누적 통계와 위험 등급까지 스스로 판단할 수 있도록 구현하였다.

 

AttendanceBook클래스

public class AttendanceBook {
    private final Map<Crew, List<Attendance>> attendanceBook = new HashMap<>();

    private static final LocalTime MONDAY_CLASS_START = LocalTime.of(13, 0);
    private static final LocalTime OTHER_DAYS_CLASS_START = LocalTime.of(10, 0);
    private static final String DUPLICATE_ATTENDANCE_ERROR = "[ERROR] 이미 출석을 확인하였습니다. 필요한 경우 수정 기능을 이용해 주세요.";
    private static final String UNKNOWN_NAME_ERROR = "[ERROR] 등록되지 않은 닉네임입니다.";
    private static final String FUTURE_DATE_ERROR = "[ERROR] 미래 날짜로는 출석을 수정할 수 없습니다.";

    private static final int LATE_PER_ABSENT = 3;

    public void fillAbsentsForThisMonth() {
        ...
    }

    private void addMissingAbsents(Crew crew, List<Attendance> records, Set<LocalDate> recordedDates,YearMonth thisMonth, LocalDate today) {
        ...
    }


    // 출석 등록
    public Attendance registerAttendance(Crew crew, String timeInput) {
        ...
    }

    // 등록되지 않은 크루면 에러 발생
    private Crew findCrewOrThrow(String name) {
        ...
    }

    // 출석 중복 방지
    private void validateAttended(Crew crew, LocalDate today) {
        ...
    }
	// 주말에 출석을 시도하면 예외 발생
    public void validateWeekend(LocalDate today,DayOfWeek day){
        ...
    }

    // 출석 수정
    public List<Attendance> modifyAttendance(String name, int dayOfMonth, String newTimeInput) {
        ...
    }
    
	// CSV 로딩 시 출석 추가
    public void addAttendance(String name, Attendance attendance) {
        ...
    }

    // 크루별 출석 목록 조회
    public List<Attendance> getAttendancesByCrew(Crew crew) {
        ...
    }

    public Crew getCrew(String name) {
        return findCrewOrThrow(name);
    }

	// 수정 날짜 검증 및 반환
    private LocalDate validateAndGetTargetDate(int dayOfMonth) {
        ...
    }

	// 기존 출석을 찾아 새 출석으로 대체
    private Attendance findAndReplace(List<Attendance> records, LocalDate targetDate, Attendance newAttendance) {
        ...
    }

	// 위험 레벨이 높은 크루 목록 반환
    public List<Crew> getAtRiskCrews() {
        ...
    }

}

기존 서비스에 있는 로직이 전부 이 출석부 객체가 가지고 있는 것을 볼 수 있다.

코드가 길어지니 책임이 너무 많은 것 아닌지 의문이 들 수도 있다. 하지만 로직을 중심으로 나누었고 자신의 책임을 수행하고 있으니 응집도는 높이고 결합도는 낮추는 방향에 맞다고 볼 수 있다.

 

리팩토링 전과 후의 구조이다.

도메인 객체가 스스로 책임을 지게 되자, Service 클래스가 자연스럽게 사라지게 된 것을 볼 수 있다.


더 생각해볼 점

  • 현재 AttendancdBook의 코드가 많다. 책임이 많은 것이다. 이럴때는 내부적으로 사용하고 있는 객체들을 포장하여 위임을 하는것이 좋다. 예를 들어 AttendanceBook의 List<Attendance>를 도메인 개념으로 포장 하여 Attendances로 클래스를 만들고 일급컬렉션으로 분리하면 된다.
  • enum도 클래스이다. 책임을 줘도 된다.
  • view에 넘길 데이터를 가공해서 넘겨줘야할까, view에서 가공해야할까.
    -> 서버에서 가공해서 넘겨주는게 좋다고 생각한다. view는 단순히 데이터를 보여주는 역할에 집중해야 하며, 복잡한 데이터 가공이나 비즈니스 로직을 포함해서는 안된다. 

 

이번 출석부 리팩토링에서는 도메인 객체의 역할을 다시 돌아보게 되었다.
도메인 주도 설계는 거창한 기술이 아니라, 비즈니스 개념을 명확하고 응집력 있게 코드로 옮기는 구조를 말한다.

기능 구현에 몰입했던 지난 구조를 반성하며,
이제는 도메인 중심으로 책임을 분산시키고 시스템이 점점 커져도 탄탄하게 유지될 수 있는 구조를 만들고자 한다.

 

https://github.com/hwanh2/java-attendance-final-

 

GitHub - hwanh2/java-attendance-final-

Contribute to hwanh2/java-attendance-final- development by creating an account on GitHub.

github.com

 

'Java' 카테고리의 다른 글

@Entity를 붙이면 일어나는 일 - JPA 내부 동작 원리  (0) 2026.02.01
JPA 영속성 컨텍스트  (0) 2026.01.31
[우아한테크코스] 로또  (2) 2025.05.02
[우아한테크코스] 자동차 경주  (0) 2025.04.29
'Java' 카테고리의 다른 글
  • @Entity를 붙이면 일어나는 일 - JPA 내부 동작 원리
  • JPA 영속성 컨텍스트
  • [우아한테크코스] 로또
  • [우아한테크코스] 자동차 경주
hwanheee
hwanheee
hwanheee 님의 블로그 입니다.
  • hwanheee
    hwanheee 님의 블로그
    hwanheee
  • 전체
    오늘
    어제
    • 분류 전체보기 (20)
      • 백엔드 (8)
      • Java (5)
      • cs (1)
      • 알고리즘 (6)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    영속성컨텍스트
    비관락
    모의투자
    우테코 레이싱카
    체결엔진
    우테코
    최소 신장 트리
    크루스칼
    다이나믹 프로그래밍
    celery
    분산락
    스프링
    rabbitmq
    레디스
    최소 스패닝 트리
    우아한테크코스
    주식 모의투자
    알고리즘
    동시성 제어
    프림
    최단경로 알고리즘
    우테코 출석부
    생성형AI
    비동기처리
    동시성
    유니온파인드
    소수 구하기
    JPA
    우테코 로또
    배낭문제
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
hwanheee
[우아한테크코스] 출석부
상단으로

티스토리툴바