티스토리 뷰

이번 포스팅에서는 저번주 금요일부터 토요일까지 마이다스 기업에서 진행되었던 해커톤에 참여하게 된 계기와 본선 진출, 그리고 3등까지 하게 된 이야기를 하려고 한다. 해커톤에 참여하기까지의 이야기는 짧게 말하고, 구현에 대해서 좀 더 깊게 말할 예정이다. 

 

 

해커톤에 참여하게 된 계기

현재 부산소프트웨어마이스터고를 다니고 있다. 부산소프트웨어마이스터고등학교와 협약을 맺은 기업들이 많은데, 그 중에서 가장 좋다고 알려진 기업들이 마이다스, 우아한형제들이 있다. 우아한형제들은 협약을 맺었다고는 하지만 다른 기업들과 다르게 채용하지 않고 그냥 지켜보는 느낌인 것 같다. 그래서 선생님들은 이 학교의 최고 아웃풋이 마이다스라고 말하고, 마이다스에 가고 싶은 학생들도 많았다. 마이다스에서는 매년 마이스터 학생들을 몇명 뽑아가는데, 뽑힐 수 있는 최적의 경로가 있다. 

 

마이다스에서 진행하는 해커톤에 참여함 -> 우승 -> 특별 채용 기회를 얻음 (혹은 겨울방학 때 인턴) -> 입사

 

바로 위 경로인데, 그 중 첫번째가 마이다스에서 진행하는 해커톤에 참여하는 것이었다. 마침 마이다스에서 해커톤을 진행한다는 소식을 듣고, 참여하게 되었다. 물론 다양한 이유 중 하나이고, 가장 큰 이유는 해커톤을 함으로써 협업과 개발능력에 대한 경험을 키우기 위해서 참여했다. 

 

 

 

1차 예선

1차 예선은 역검과 개발능력구현 검사를 했다. 역검은 총 3가지 카테고리가 있는데, 다음과 같다. 

 

 

성향파악과 전략게임은 난이도가 괜찮았다. 너무 어렵지도 않았고 이해하면 쉽게 응시할 수 있는 과정이었다. 영상면접은 마이크와 카메라를 키고 질문하는 말에 답을 해야하는 과정인데, 이것도 그냥 솔직하게 말했던 것 같다. 

 

개발능력구현 검사는 코딩테스트라고 보면 된다. 하지만 문제 자체는 코테형식이 아닌 구현문제다. 주어진 문제를 제한시간 안에 풀면 결과가 나온다. 문제는 몇 개인지 모르겠는데 다양하게 나왔다. 쉬운 평균 구하기부터 컴파일러 구현까지.. 나는 검색 추천이 나왔다. 개인적으로 어려웠었다. 

 

 

합격과 팀 매칭

 

합격 알람이 왔고, OT와 설문조사를 하고 금요일날 해커톤을 진행하기 위해 서울로 올라갔다. 총 80명 정도가 합격을 했고, 팀 매칭은 크게 앱과 웹이 있었는데 프론트(앱)와 백엔드의 매칭이 좋았다. (극단적 랜덤으로 백엔드 4명이라던지 프론트 4명 이런 경우가 없었다)

나는 IOS 앱 두명과 같은 팀이 되었다. 

 

해커톤 주제

1. 주제
: 유연근무제 출퇴근 관리 시스템
“유연근무제”란 통상의 근무시간·근무일을 변경하거나 근로자와 사용자가 근로시간이나 근로장소 등을 선택·조정하여 일과 생활을 조화롭게 하고, 인력활용의 효율성을 높일 수 있는 제도입니다. – 고용노동부”

2. 상세 설명
: 최근 들어, 회사가 업무의 형태를 일률적으로 관리하는 방식에서 벗어나서 각 조직과 구성원이 자율성을 가지고 근무시간을 결정하는 유연근무제를 도입하는 회사가 점차 증가하고 있다. 구성원의 업무 생산성 향상과 복지 측면에서 필요성이 대두되고 있는 이 제도에 대해서, 관리자는 유연근무제를 진행하면서도 근무에 대한 필수적인 관리와 모니터링을 진행하고 구성원은 그 안에서 최대한 편리하게 유연근무제를 활용할 수 있도록 하도록 하는 사내에서 사용할 수 있는 유연근무 관리 솔루션을 개발한다.
-
문제와 더불어 구현해야할 사항들은 목요일에 안내될 예정이며,
지원자 분들은 유연근무제가 무엇인지, 출퇴근 관리 시스템이 무엇인지에 대해 찾아보시는 것을 추천드립니다.

 

해커톤 주제는 유연근무제였다. 목요일에 말해준 구현해야할 사항들은 아래와 같다. 

 

  • 구성원이 출근, 퇴근을 표시할 수 있다.
  • 구성원은 오늘 얼마나 일했는지, 얼마나 남았는지 알 수 있게 한다.
  • 관리자는 각 사원들의 이름을 수정할 수 있다. 

 

구현

일단 구현할 기능들을 정리했다. 

 

회원가입/로그인
1. 이름과 비밀번호를 입력해 회원가입을 할 수 있음
2. 비밀번호는 암호화시켜서 자동으로 보관함
3. 일반 유저 계정과 관리자 계정이 나뉘어져 있음

유저 계정으로 로그인 시
1. 근태 정보 화면으로 넘어감
2. 출근버튼을 누르면 상태가 출근중으로 바뀌고 타이머가 돌아감
3. 12시부터 1시까지는 스케쥴링으로 상태를 휴식중으로 바꾸고, 타이머를 멈춤
4. 현재 근무중인 다른 회원들을 조회할 수 있음
5. 현재 근무중이지 않은 회원들을 조회할 수 있음
6. 퇴근 버튼을 누르면 타이머가 종료되고 유저의 통계에 저장됨

관리자 계정으로 로그인 시
1. 유저와 똑같이 현재 근무중인, 근무중이지 않은 회원들을 볼 수 있음
2. 유저를 클릭하면 해당 유저의 상세정보가 뜸
- 상세정보 :
- 현재 위치
- 이름
- 출퇴근 상태
- 지금까지 일한 시간
- 남은 시간
3. 회원의 이름을 수정할 수 있음

 

 

먼저 회원가입과 로그인을 구현했다. 

public void join(UserJoinRequestDto requestDto) {
        if(userRepository.findByName(requestDto.getName()).isPresent()) {
            throw new UserException(UserExceptionType.ALREADY_EXIST_NAME);
        }

        User user = requestDto.toEntity();
        user.addAuthorityUser();
        user.encodedPassword(passwordEncoder);
        userRepository.save(user);
    }

    public TokenResponseDto login(UserLoginRequestDto requestDto) {
        if(userValidator.isAdminUser(requestDto)) {
            return userValidator.loginAdmin(requestDto);
        }
        User user = userRepository.findByName(requestDto.getName())
                .orElseThrow(() -> new UserException(UserExceptionType.NOT_SIGNUP_NAME));

        if(!passwordEncoder.matches(requestDto.getPassword(), user.getPassword())) {
            throw new UserException(UserExceptionType.WRONG_PASSWORD);
        }

        return userValidator.responseDto(false, requestDto.getName());
    }
@Getter
@NoArgsConstructor
public class UserJoinRequestDto {

    @NotNull(message = "이름을 입력해주세요.")
    @Size(min = 1, max = 20)
    private String name;

    @NotNull(message = "비밀번호를 입력해주세요.")
    @Pattern(regexp = "(?=.*\\W)(?=\\S+$).{5,20}",
            message = "알파벳과 특수기호가 적어도 1개씩 사용해주세요")
    private String password;

    public User toEntity() {
        return User.builder()
                .name(name)
                .password(password)
                .build();
    }
}

 

이름과 비밀번호를 통해서 회원가입을 하고,  관리자 계정으로 로그인을 따로 구현하였다. 

 

    public boolean isAdminUser(UserLoginRequestDto requestDto) {
        return requestDto.getName().equals(AdminUtil.ADMIN_NAME) && requestDto.getPassword().equals(AdminUtil.ADMIN_PASSWORD);
    }

 

암호화 시키는 코드는 아래와 같다.

 

    public void encodedPassword(PasswordEncoder passwordEncoder) {
        this.password = passwordEncoder.encode(password);
    }

 

이제 유저 계정으로 로그인 시 근태 정보 화면으로 넘어가야 한다. 

 

    @Transactional(readOnly = true)
    public AttendanceResponseDto getUserAttendance() {
        User user = attendanceValidator.validateUser();

        Attendance attendance = attendanceQuerydslRepository.getAttendanceByUser(user)
                .orElseThrow(() -> new AttendanceException(AttendanceExceptionType.NOT_START_ATTENDANCE_YET));

        if(attendance.getAttendanceStatus().equals(AttendanceStatus.DUTY)) {
            attendance.updateTimes(LocalTime.now().withNano(0));
        }

        return AttendanceResponseDto.builder()
                .attendance(attendance)
                .build();
    }

 

먼저 사용자가 로그인을 했는지 확인한다. 

 

    public User validateUser() {
        return userRepository.findByName(SecurityUtil.getLoginUserEmail())
                .orElseThrow(() -> new UserException(UserExceptionType.REQUIRED_DO_LOGIN));
    }

 

그리고 근태정보를 가져오는데, 만약 등록하지 않았다면 등록하라고 말한다.

 

    public Optional<Attendance> getAttendanceByUser(User user) {
        return Optional.ofNullable(queryFactory
                .selectFrom(attendance)
                .where(attendance.user.eq(user).and(attendance.today.eq(LocalDate.now())))
                .fetchOne());
    }

 

위 코드는 오늘 근태 정보를 가져오는데, 이유는 설계를 아래와 같이 해서 그렇다. 

 

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Attendance {

	...
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "USERS")
public class User {

	...

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private final List<Attendance> attendances = new ArrayList<>();

 

이렇게 설계한 이유는 사용자는 매일마다 출근, 퇴근을 한다. 그래서 사용자가 매일마다 출근, 퇴근, 얼마나 일했는지, 얼마나 남았는지를 저장해두었다가 프로필에 그래프로 띄우고, 오늘은 오늘 근태 정보만 가져오도록 했다. 

 

만약 등록하지 않았다면 등록해야 한다. 출석하는 코드는 다음과 같다. 

 

public void createAttendance(MapPointCreateRequestDto requestDto) {
        User user = attendanceValidator.validateUser();

        if(attendanceQuerydslRepository.getAttendanceByUser(user).isPresent()) {
            throw new AttendanceException(AttendanceExceptionType.ALREADY_DUTY);
        }

        mapPointService.createMapPoint(requestDto, user);

        Attendance attendance = Attendance.builder()
                .startTime(LocalTime.now())
                .today(LocalDate.now())
                .build();

        attendance.confirmUser(user);

        attendance.addAttendanceDuty();
        attendanceRepository.save(attendance);
    }

 

mapPoint는 나중에 설명하겠다. 이렇게 등록을 하면, 근태 상태가 근무중으로 바뀐다. 바뀌는 코드는 아래와 같다. 

 

    public void addAttendanceDuty() {
        this.attendanceStatus = AttendanceStatus.DUTY;
    }
@Getter
public enum AttendanceStatus {
    DUTY("근무중"),
    LEAVE_WORK("퇴근"),
    REST("휴식중"),
    HOME("재택근무중");

    private final String name;

    AttendanceStatus(String name) {
        this.name = name;
    }
}

 

다시 자신의 근태 정보를 띄우면 아래와 같이 뜨게 된다. 

 

 

오늘 일할 시간은 8시간으로 고정했다. 

 

private final LocalTime todayTotalWorkTime = LocalTime.of(8, 0); //오늘 일할 시간

    private LocalTime startTime = LocalTime.of(0, 0, 0); //출근한 시간

    private LocalTime workTime = LocalTime.of(0, 0, 0); //지금까지 일한 시간

    private LocalTime remainingTime = LocalTime.of(0, 0, 0); //남은 시간

    private LocalDate today; //오늘 근태 정보인지 확인하기 위함 

    private LocalTime startRestTime = LocalTime.of(0, 0, 0); //휴식시간

 

이제 자신의 근태 정보를 볼 때마다 시간이 바뀌는데, 코드는 아래와 같다. 

 

if(attendance.getAttendanceStatus().equals(AttendanceStatus.DUTY)) {
            attendance.updateTimes(LocalTime.now().withNano(0));
        }

 

현재 시간을 updateTimes 메서드에 넣었다. 아래는 updateTimes 메서드다. 

 

    public void updateTimes(LocalTime now) {
        this.workTime = now.minusHours(this.startTime.getHour()).withNano(0);
        this.workTime = this.workTime.minusMinutes(this.startTime.getMinute()).withNano(0);
        this.workTime = this.workTime.minusSeconds(this.startTime.getSecond()).withNano(0);
        this.remainingTime = this.todayTotalWorkTime.minusHours(this.workTime.getHour()).withNano(0);
        this.remainingTime = this.remainingTime.minusMinutes(this.workTime.getMinute()).withNano(0);
        this.remainingTime = this.remainingTime.minusSeconds(this.workTime.getSecond()).withNano(0);
    }

 

최대한 라이브러리를 써서 깔끔하게 구현하려고 했는데, 한번에 시분초 - 시분초 가 가능한게 없어서 일일히 작성했다.. 

 

계산은 다음과 같다.

  • 지금까지 일한 시간 : 현재 시간 - 출근한 시간
  • 남은 시간 : 8시간 - 지금까지 일한 시간

 

그리고 회원은 다른 회원들이 활동중인지 활동중이지 않은지를 조회할 수 있다. 

 

    @Transactional(readOnly = true)
    public List<AttendanceAllResponseDto> getDutyUserAttendances() {
        return attendanceRepository.findAll().stream()
                .filter(attendance -> attendance.getToday().compareTo(LocalDate.now()) == 0)
                .filter(attendance -> attendance.getAttendanceStatus().equals(AttendanceStatus.DUTY))
                .map(AttendanceAllResponseDto::new)
                .collect(Collectors.toList());
    }
    @Transactional(readOnly = true)
    public List<AttendanceAllResponseDto> getNotDutyUserAttendances() {
        return attendanceRepository.findAll().stream()
                .filter(attendance -> attendance.getToday().compareTo(LocalDate.now()) == 0)
                .filter(attendance -> !attendance.getAttendanceStatus().equals(AttendanceStatus.DUTY))
                .map(AttendanceAllResponseDto::new)
                .collect(Collectors.toList());
    }

 

이제 퇴근을 할 수 있어야 한다. 코드는 아래와 같다. 

 

    public AttendanceResponseDto updateAttendanceStatus() {
        User user = attendanceValidator.validateUser();

        return attendanceQuerydslRepository.getAttendanceByUser(user)
                .map(attendance -> {
                    attendance.addAttendanceLeaveWork();
                    attendance.updateTimes(LocalTime.now().withNano(0));
                    return AttendanceResponseDto.builder().attendance(attendance).build();
                })
                .orElseThrow(() -> new AttendanceException(AttendanceExceptionType.NOT_START_ATTENDANCE_YET));
    }

 

이제 12시부터 1시까지는 스케쥴링으로 상태를 휴식중으로 바꾸고, 타이머를 멈춤을 구현해야 한다. 스케쥴링을 이용했다. 

 

    @Scheduled(cron = "0 0 12 * * *")
    public void startRest() {
        Attendance attendance = attendanceValidator.validateAttendanceByUser();

        if(!attendanceValidator.isLeaveWorkUser(attendance)) {
            attendance.addAttendanceRest();
        }
        attendance.startRestTime(LocalTime.now());
    }

 

자신의 근태정보가 있는지 확인하고, 만약 퇴근일 때 휴식중이라 뜨면 안되니까 근무중일 때만 휴식중으로 바뀌게 했다. 

 

    @Scheduled(cron = "0 0 1 * * *")
    public void endRest() {
        Attendance attendance = attendanceValidator.validateAttendanceByUser();
        LocalTime restTime = LocalTime.now().minus(attendance.getStartRestTime().getMinute(), ChronoUnit.SECONDS);
        attendance.updateTimes(restTime.withNano(0));

        if(!attendanceValidator.isLeaveWorkUser(attendance)) {
            attendance.addAttendanceDuty();
        }
    }

 

그리고 휴식이 끝날 때가 중요한데, 휴식한 시간 (1시간)을 현재 시간에서 빼준다. 그리고 근태 정보에 적용한 후 다시 근무중으로 변경한다. 

하지만 해커톤 막바지에 스케쥴링하는 코드에서 오류가 떠 동작하지 못했다.. 오류가 난 이유는 로그인이 유지되었는지 확인할 때 실행되는 SecurityContext안에 유저 아이디가 있는지 확인하는 코드에서 NPE가 떴기 때문이다. 하지만 고치치 못했다. 

 

이제 관리자가 로그인 시 구현해야 하는 상황은 두 가지다.

 

1. 유저를 클릭하면 해당 유저의 상세정보가 뜸
- 상세정보 :
- 현재 위치
- 이름
- 출퇴근 상태
- 지금까지 일한 시간
- 남은 시간
2. 회원의 이름을 수정할 수 있음

 

해당 유저의 상세정보를 띄우는 건 간단하다. 

 

    @Transactional(readOnly = true)
    public AdminUserDetailResponseDto getUser(Long id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new UserException(UserExceptionType.NOT_FOUND_USER));

        Attendance attendance = attendanceQuerydslRepository.getAttendanceByUser(user)
                .orElseThrow(() -> new AttendanceException(AttendanceExceptionType.NOT_START_ATTENDANCE_YET));

        return AdminUserDetailResponseDto.builder()
                .attendance(attendance)
                .build();
    }

 

상세정보를 띄울 유저가 있는지 확인하고, 그 유저가 출근버튼을 눌렀는지 확인하고 띄우면 된다. 

 

@Getter
public class AdminUserDetailResponseDto {
    private final double latitude;
    private final double longitude;
    private final String name;
    private final String status;
    private final LocalTime workTime;
    private final LocalTime remainingTime;
    //TODO : 소속부서

    @Builder
    public AdminUserDetailResponseDto(Attendance attendance) {
        this.latitude = attendance.getUser().getPoint().getLatitude();
        this.longitude = attendance.getUser().getPoint().getLongitude();
        this.name = attendance.getUser().getName();
        this.status = attendance.getAttendanceStatus().getName();
        this.workTime = attendance.getWorkTime();
        this.remainingTime = attendance.getRemainingTime();
    }
}

 

이제 여기서 mapPoint를 설명하겠다. 만약 내가 클럽에서 출근버튼을 눌렀는지 집에서 눌렀는지 알 수 없다. 그래서 생각한게 출근 버튼을 누를 시 현재 위치를 저장한 후 관리자가 볼 수 있게 하는 것이었다. 그래서 출근을 등록하는 로직에서 createMapPoint가 있는데, 아래와 같다. 그냥 현재 위치의 위도와 경도를 저장하는 코드다. 

 

public void createMapPoint(MapPointCreateRequestDto requestDto, User user) {
        if(mapPointRepository.existsByUser(user)) {
            throw new MapPointException(MapPointExceptionType.ALREADY_CREATE_MAP_POINT);
        }

        MapPoint point = requestDto.toEntity();
        point.confirmUser(user);

        mapPointRepository.save(point);
    }

 

그리고 원하는 유저의 상세정보를 볼 때 위치도 같이 띄워준다. 

 

 

그리고 회원의 이름을 변경할 수 있다. 

 

    public void updateUserName(Long id, AdminUpdateUserRequestDto requestDto) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new UserException(UserExceptionType.NOT_FOUND_USER));

        user.updateUserName(requestDto.getName());
    }

 

개인적으로 변수나 메서드명이 너무 아쉽다. 그리고 한 메서드 안에 여러 동작을 하고 있고, 각각의 역할을 지키지 못한 것 같다. 기회가 되면 고쳐야 겠다! API 문서깃허브를 참고해주세요.

 

 

3등

이렇게 무박이 끝이 났고, 이제 발표와 시상식만 남겨두고 있었다. 예선 발표는 조 대표가 마이다스 개발자 2분 앞에서 발표하는 형식이었다. 그리고 본선으로 진출한 팀들은 모두가 보는 앞에서 발표한다. 솔직히 본선으로 뽑힐 줄 몰랐는데, 우리 조 이름을 불러서 놀랐다. 본선에는 6팀이 진출하고, 그 중 5팀이 상금을 받는다. 1등 천만원, 공동 2등 600만원, 공동 3등 300만원이었다. (사실상 5등이나 다름없었다) 우리는 공동 3등을 했고, 300만원을 받았다. 결코 쉬운 경험은 아니었지만, 요구사항을 정확하고 빠르게 분석하고 협업하는게 중요하다는 것을 알게 되었다. 값진 경험이었다. 

 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday