https://school.programmers.co.kr/learn/courses/30/lessons/42888

문제

카카오톡 오픈채팅방에서는 친구가 아닌 사람들과 대화를 할 수 있는데, 본래 닉네임이 아닌 가상의 닉네임을 사용하여 채팅방에 들어갈 수 있다.

신입사원인 김크루는 카카오톡 오픈 채팅방을 개설한 사람을 위해, 다양한 사람들이 들어오고, 나가는 것을 지켜볼 수 있는 관리자창을 만들기로 했다. 채팅방에 누군가 들어오면 다음 메시지가 출력된다.

"[닉네임]님이 들어왔습니다."

채팅방에서 누군가 나가면 다음 메시지가 출력된다.

"[닉네임]님이 나갔습니다."

채팅방에서 닉네임을 변경하는 방법은 다음과 같이 두 가지이다.

  • 채팅방을 나간 후, 새로운 닉네임으로 다시 들어간다.
  • 채팅방에서 닉네임을 변경한다.

닉네임을 변경할 때는 기존에 채팅방에 출력되어 있던 메시지의 닉네임도 전부 변경된다.

예를 들어, 채팅방에 "Muzi"와 "Prodo"라는 닉네임을 사용하는 사람이 순서대로 들어오면 채팅방에는 다음과 같이 메시지가 출력된다.

"Muzi님이 들어왔습니다."
"Prodo님이 들어왔습니다."

채팅방에 있던 사람이 나가면 채팅방에는 다음과 같이 메시지가 남는다.

"Muzi님이 들어왔습니다."
"Prodo님이 들어왔습니다."
"Muzi님이 나갔습니다."

Muzi가 나간후 다시 들어올 때, Prodo 라는 닉네임으로 들어올 경우 기존에 채팅방에 남아있던 Muzi도 Prodo로 다음과 같이 변경된다.

"Prodo님이 들어왔습니다."
"Prodo님이 들어왔습니다."
"Prodo님이 나갔습니다."
"Prodo님이 들어왔습니다."

채팅방은 중복 닉네임을 허용하기 때문에, 현재 채팅방에는 Prodo라는 닉네임을 사용하는 사람이 두 명이 있다. 이제, 채팅방에 두 번째로 들어왔던 Prodo가 Ryan으로 닉네임을 변경하면 채팅방 메시지는 다음과 같이 변경된다.

"Prodo님이 들어왔습니다."
"Ryan님이 들어왔습니다."
"Prodo님이 나갔습니다."
"Prodo님이 들어왔습니다."

채팅방에 들어오고 나가거나, 닉네임을 변경한 기록이 담긴 문자열 배열 record가 매개변수로 주어질 때, 모든 기록이 처리된 후, 최종적으로 방을 개설한 사람이 보게 되는 메시지를 문자열 배열 형태로 return 하도록 solution 함수를 완성하라.

제한사항
  • record는 다음과 같은 문자열이 담긴 배열이며, 길이는 1 이상 100,000 이하이다.
  • 다음은 record에 담긴 문자열에 대한 설명이다.
    • 모든 유저는 [유저 아이디]로 구분한다.
    • [유저 아이디] 사용자가 [닉네임]으로 채팅방에 입장 - "Enter [유저 아이디] [닉네임]" (ex. "Enter uid1234 Muzi")
    • [유저 아이디] 사용자가 채팅방에서 퇴장 - "Leave [유저 아이디]" (ex. "Leave uid1234")
    • [유저 아이디] 사용자가 닉네임을 [닉네임]으로 변경 - "Change [유저 아이디] [닉네임]" (ex. "Change uid1234 Muzi")
    • 첫 단어는 Enter, Leave, Change 중 하나이다.
    • 각 단어는 공백으로 구분되어 있으며, 알파벳 대문자, 소문자, 숫자로만 이루어져있다.
    • 유저 아이디와 닉네임은 알파벳 대문자, 소문자를 구별한다.
    • 유저 아이디와 닉네임의 길이는 1 이상 10 이하이다.
    • 채팅방에서 나간 유저가 닉네임을 변경하는 등 잘못 된 입력은 주어지지 않는다.

접근

  • 결과 출력에는 최종으로 변경된 닉네임만 출력된다.
  • 유저 아이디는 고유하고 마지막 닉네임만 기억하면 되기 때문에 Map<유저 아이디, 닉네임> 으로 유저 아이디의 최종 닉네임을 저장한다.
    • record를 for문으로 돌면서 Enter이나 Change이면 map.put(유저 아이디, 닉네임)
    • map.put은 key값이 존재하면 replace 하기 때문에 map.put으로 새로 입장, 변경 모두 처리할 수 있다.
  • 새로 for문을 돌면서 map에서 아이디에 해당하는 닉네임을 찾아 꺼내고, 출력 형식에 맞게 출력한다.

 

코드

(https://school.programmers.co.kr/learn/courses/30/lessons/42889)

문제

슈퍼 게임 개발자 오렐리는 큰 고민에 빠졌다. 그녀가 만든 프랜즈 오천성이 대성공을 거뒀지만, 요즘 신규 사용자의 수가 급감한 것이다. 원인은 신규 사용자와 기존 사용자 사이에 스테이지 차이가 너무 큰 것이 문제였다.

이 문제를 어떻게 할까 고민 한 그녀는 동적으로 게임 시간을 늘려서 난이도를 조절하기로 했다. 역시 슈퍼 개발자라 대부분의 로직은 쉽게 구현했지만, 실패율을 구하는 부분에서 위기에 빠지고 말았다. 오렐리를 위해 실패율을 구하는 코드를 완성하라.

  • 실패율은 다음과 같이 정의한다.
    • 스테이지에 도달했으나 아직 클리어하지 못한 플레이어의 수 / 스테이지에 도달한 플레이어 수

전체 스테이지의 개수 N, 게임을 이용하는 사용자가 현재 멈춰있는 스테이지의 번호가 담긴 배열 stages가 매개변수로 주어질 때, 실패율이 높은 스테이지부터 내림차순으로 스테이지의 번호가 담겨있는 배열을 return 하도록 solution 함수를 완성하라.

제한사항
  • 스테이지의 개수 N은 1 이상 500 이하의 자연수이다.
  • stages의 길이는 1 이상 200,000 이하이다.
  • stages에는 1 이상 N + 1 이하의 자연수가 담겨있다.
    • 각 자연수는 사용자가 현재 도전 중인 스테이지의 번호를 나타낸다.
    • 단, N + 1 은 마지막 스테이지(N 번째 스테이지) 까지 클리어 한 사용자를 나타낸다.
  • 만약 실패율이 같은 스테이지가 있다면 작은 번호의 스테이지가 먼저 오도록 하면 된다.
  • 스테이지에 도달한 유저가 없는 경우 해당 스테이지의 실패율은 0 으로 정의한다.

접근

  • 각 스테이지별로 실패율을 구한다
    • stages를 for문을 돌면서 1부터 N까지 i와 같은 stage는 스테이지에 도달했으나 아직 클리어하지 못한 플레이어의 수
    • stages for문을 돌면서 1부터 N까지 i 이상인 stage는 스테이지에 도달한 플레이어 수
    • 실패율과 index(스테이지의 번호)를 Stage 객체에 저장한다
    • List에 Stage를 저장한다.
  • List<Stage>를 정렬한다
    • Stage는 Comparable<Stage>를 implement 한다.
      • 정렬하기 위한 순서 비교가 가능하도록 compareTo(Stage o)를 @Override한다.
      • 정렬 순서는 실패율이 같으면 작은 스테이지가 앞 순서, 다르면 실패율이 큰 것을 앞 순서로 한다
  • 정렬된 List<Stage>에서 순차적으로 Stage의 index를 꺼내서 answer에 저장한다.

 

코드

다음과 같은 테이블 구조에서 쿼리를 작성하게 되었다.

필요한 쿼리는 memberId로 pomodoro_prgoress들을 찾아서 pomodoro_room을 조회하는 것.

1. 쿼리를 두 번 나눠서 실행하기

List<PomodoroProgress> pomodoroProgresses = em.createQuery("select pp from PomodoroProgress pp where pp.member.id = :memberId", PomodoroProgress.class)
        .setParameter("memberId", memberId)
        .getResultList();

 

먼저 memberId가 같은 pomodoroProgress를 찾는다.

List<PomodoroRoom> pomodoroRooms = pomodoroProgresses.stream()
        .map(PomodoroProgress::getPomodoroRoom)
        .distinct()
        .toList();

그 다음 메모리에서 스트림을 돌면서 getPomodoroRoom을 통해 객체 그래프 탐색으로 PomodoroRoom들을 찾는다. PomodoroProgress와 PomodoroRoom은 다대일 관계이기 때문에 distinct를 해주었다. 프록시 객체를 생성할 때 id가 같으면 동일한 객체를 반환하는 것 같다. distinct를 제거하고 중복을 만들어서 출력을 해보니 해시 코드와, 동일한 프록시 객체임을 확인했다.

주의해야 할 점은 getPomodoroRoom까지만 수행했을 때는 PomodoroRoom이 프록시 상태이지만 distinct 연산을 수행하게 되면 쿼리를 수행해 실제 객체를 가져온다는 점을 인지해야 한다.

이러한 방식은 메모리에서 스트림을 돌면서 PomodoroRoom을 찾기 때문에 데이터가 많아지면 메모리 문제나 속도 문제가 발생할 수 있다.

 

2. 서브 쿼리

return em.createQuery("select pr from PomodoroRoom pr where pr in (select pp.pomodoroRoom from PomodoroProgress pp where pp.member.id = :memberId)", PomodoroRoom.class)
        .setParameter("memberId", memberId)
        .getResultList();

 

1번에서 수행한 쿼리보다 조금 더 나은 선택지. 한 번에 쿼리를 수행하며 메모리에서 연산을 하지도 않는다.

작성하기 쉽고 직관적이기도 하다.

하지만 서브 쿼리는 성능 최적화가 잘 안되고 느리다는 단점을 갖고 있다.

직접 확인해보기 위해 memberId가 1이고 pomodoroRoom1을 참조하는 PomodoroProgress를 1만개 저장하고 조회해봤더니 위와 같이 크게 느리지는 않은 것을 확인했다.

 

3. 조인

가장 빠른 방법.

return em.createQuery("select pr from PomodoroRoom pr join pr.pomodoroProgresses pp where pp.member.id = :memberId", PomodoroRoom.class)
        .setParameter("memberId", memberId)
        .getResultList();

 

쿼리가 한 번에 수행된다.

그런데 속도를 측정해보니 조인 쿼리가 더 느리다...?

서브쿼리를 사용하는 jpql쿼리를 수행했을 때 발생하는 sql쿼리를 복사해서 쿼리 실행계획을 확인해봤다.

풀테이블 스캔을 실행하며 인덱스를 타지 않는다. 그런데 select_type을 보면 jpql 쿼리를 서브 쿼리로 작성했음에도 subquery가 아닌 simple로 조회가 되는것을 확인했다. 다시 복사해온 쿼리를 확인해 보니 sql쿼리는 서브 쿼리로 수행되지 않고 있다.

조인쿼리의 쿼리 실행계획을 확인해봤다.

pomodoro_room인 p1은 풀테이블 스캔을 실행하며 인덱스를 타지 않고, pomodoro_progress인 p2는 기본키로 인덱스를 탄다.

두 쿼리 모두 simple 타입으로 수행되고 있고 인덱스 등 최적화도 똑같이 이루어지고 있지 않기 때문에 하나의 테이블을 조회하는 서브 쿼리 쪽이 더 속도가 빨랐다는 결론을 내릴 수 있다.

결론

원하는 데이터를 조회하기 위한 여러 방법이 쿼리를 작성해 보았고 장단점들을 비교해보았다.

단순히 궁금증에서 시작했던 시간 측정에서 jpql을 서브 쿼리가 더 빨리 수행된다는 예상과는 다른 결과를 확인했고, 이 원인을 분석하기 위해 실제 수행되는 sql 문으로 쿼리 실행 계획을 확인해보았다. 서브쿼리로 작성한 jpql의 실제로 수행되는 sql문은 서브쿼리가 아니였고 이에 따라 더 빠른 속도를 내는 것을 확인했다. 쿼리의 속도를 예상만 하지 말고 속도를 측정하고 쿼리 실행 계획을 확인하자.

study를 조회할 때 @PathVariable로 studyId로 조회하는 것과 @RequestParam으로 participantCode로 조회하는 것을 묶어보았다.

    @GetMapping("/api/studies/{studyId}")
    public ResponseEntity<PomodoroRoomResponse> findStudy(
            @PathVariable(required = false) Long studyId,
            @RequestParam(required = false) String participantCode
    ) {
        PomodoroRoomResponse pomodoroRoom = pomodoroRoomService.findPomodoroRoomWithFilter(studyId,
                participantCode);
        return ResponseEntity.ok(pomodoroRoom);
    }

 

 

 

 

 

@PathVariable과 @RequestParam 둘 다 require가 default ture이고 required = false로 설정하면 다음과 같이 두 요청 모두 핸들링이 가능하다고 생각했다.

- /api/stuides/{studyId}

- /api/studies?participantCode

하지만 @PathVVariable의 required를 false를 하더라도 다음과 같이 예상한대로 작동하지는 않는다.

@PathVariable에 required를 false를 하더라도 /api/stuides/{studyId} 형태가 아니면 핸들러 맵핑이 안되는 것.

이러한 경우는 핸들러를 분리하여 따로 작성해야 한다.

@OneToMany 관계에서의 N + 1 문제 분석하기

@OneToMany 관계에서 N + 1 문제가 발생할 때 실제로 몇 번의 쿼리가 더 수행되는지 질문을 받았다. 지연 로딩이든 즉시 로딩이든 One 쪽의 개수만큼 쿼리가 추가로 발생한다고 대답했으나 다른 경우는 없는지 확신을 갖고 대답하지 못했다. 따라서 직접 SQL 쿼리문이 어떻게 나가는지 확인하고 몇 개의 쿼리가 추가로 수행되는지 확인해보고자 한다.

다음은 One에 해당하는 Team 엔티티이다. (생성자나, getter, setter등은 생략했다.)

연관관계의 주인(외래키를 관리하는)은 Many쪽이므로 mappedBy로 양방향 맵핑을 해주었다.

@Entity
public class Team {

    @Id
    @GeneratedValue
    private Long id;
    
    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();

    ...
}

 

다음은 Many에 해당하는 Member 엔티티이다. 연관관계의 주인이기 때문에 @JoinColumn으로 team_id를 외래키로 명시했다.

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;
    
    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
    
    ...
}

 

이제 테스트 코드를 통해 쿼리문이 어떻게 수행되는지 직접 확인해보자.

@DataJpaTest
class TeamRepositoryTest {
    
    @Autowired
    private TeamRepository teamRepository;
    @Autowired
    private MemberRepository memberRepository;
    @Autowired
    private TestEntityManager em;
    
    @Test
    void oneToMany() {
        Team team1 = new Team("team1");
        Team team2 = new Team("team2");
        Member member1 = new Member("member1");
        Member member2 = new Member("member2");
        Member member3 = new Member("member3");
        Member member4 = new Member("member4");
        member1.setTeam(team1);
        member2.setTeam(team1);
        member3.setTeam(team1);
        member4.setTeam(team2);
        teamRepository.save(team1);
        teamRepository.save(team2);
        memberRepository.save(member1);
        memberRepository.save(member2);
        memberRepository.save(member3);
        memberRepository.save(member4);

        em.flush();
        em.clear();

        System.out.println("=====select=====");
        List<Team> teams = teamRepository.findAll();

        for (Team team : teams) {
            for (Member member : team.getMembers()) {
                System.out.println("member.getUsername() = " + member.getUsername());
            }
        }
    }
}

 

team1과 team2 두 개의 Team을 생성하고, 4개의 Member를 생성하여 member1,2,3은 team1로, member4는 team2로 설정했다.

JpaRepository를 상속하는 각 TeamRepository와 MemberRepository를 통해 엔티티를 영속화하고, TestEntityManger를 통해 영속성 컨텍스트를 flush하고, clear해주었다. (이후 조회시 쿼리문을 수행하지 않고 1차 캐시에서 가져오는 것을 방지하기 위함)

이제 실제로 수행되는 쿼리를 확인해보자.

우선 team을 먼저 가져온다.

다음으로는 for문을 돌면서 team1에서 member.getUsername()을 처음 호출할 때 member.team_id가 team1과 같은 member를 모두 가져온다.

빨간색 박스 부분의 where절을 보면 쿼리 한 번으로 team1에 해당하는 member1,2,3을 가져온다는 것을 확인할 수 있다.

그 다음 for문을 돌면서 team2에서 member.getUsername()을 호출할 때 똑같이 member.team_id가 team2와 같은 member를 가져온다.

위와 같이9 쿼리가 한 번더 수행되고 team2에 해당하는 member4를 가져온다.

 

결론

member1, 2, 3을 team1과 맵핑하고 member4를 team2와 맵핑하고 모든 Team을 조회하여 Team의 member.getUsername으로 출력했을 때 수행되는 쿼리는 1(Team을 조회하는 쿼리) + 2(team_id에 해당하는 member를 조회하는 쿼리)로 총 3번이 수행되었다. 추가로 수행된 2번의 쿼리는 Team의 개수만큼 추가로 수행된 것이므로 @OneToMany 관계에서 One의 개수만큼 쿼리가 추가로 수행되는 것을 확인했다.

@OneToMany 관계에서 외래키를 target table에 두기 위해 다음과 같이 작성했었다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class PomodoroRoom {
	...
    @OneToMany(mappedBy = "pomodoroRoom", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<PomodoroProgress> pomodoroProgresses = new ArrayList<>();
    ...
}

실제로 필요한 맵핑은 PomodoroRoom에서의 단방향만 필요했으나 @JoinColumn을 사용한 쪽에서 외래키를 관리한다고 생각하여 Many쪽에서 맵핑을 해주고 One쪽에서 mappedBy로 양방향 맵핑을 해주었다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class PomodoroProgress {
    ...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "pomodoro_progress_id")
    private PomodoroProgress pomodoroProgress;
    ...
}

 

하지만 굳이 그럴 필요가 없었다..!

@JoinColumn의 주석에 있는 설명을 보면 다음과 같다.

 

조인하고자 하는 대상에 따라 다음과 같이 적용된다.

  • OneToOne 또는 ManyToOne인 경우 외래키는 source entity 또는 embeddable에 존재한다.
  • OneToMany 단방향 맵핑인 경우 외래키는 target entity에 존재한다.
  • ManyToMany 맵핑 또는 OneToOne 양방향 맵핑인 경우 외래키는 join table에 존재한다.
  • element collection인 경우 외래키는 collection table에 존재한다.

나는 두 번째 케이스로 사용하면 되는것이었다. 불필요하게 양방향 맵핑을 하고 있었던 것...

이로써 불필요한 양방향 맵핑을 줄일 수 있게 되었다.

이를 깨우치게 해준 허브신께 무한한 감사를...

다음 날이 2차 스프린트의 마지막 날이기 때문에 오늘 배포를 하고 api를 직접 테스트 했다.

테스트 도중 비즈니스 로직을 수행해도 엔티티가 DB에 제대로 저장되지 않는 문제가 발생했다.

Study에 Member가 참여하기 위해 ParticipateService에서 study.participate(member) 를 수행하면 멤버의 스터디 참여 정보인 MemberProgress 엔티티가 생성되고 저장돼야 한다.

서비스 부터 코드를 살펴보자.

@RequiredArgsConstructor
@Transactional
@Service
public class ParticipateService {

    private final MemberRepository memberRepository;
    private final StudyRepository studyRepository;

    public Long participate(Long studyId, String nickname) {
        Member member = memberRepository.save(new Member(nickname));
        Study study = studyRepository.findById(studyId)
                .orElseThrow(IllegalArgumentException::new);

        study.participate(member);
        return member.getId();
    }
}

 

닉네임을 받아 멤버를 생성하고, 이 멤버를 스터디에 참여시킨다.

 

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "study_type")
public abstract class Study extends BaseTimeEntity {

    private static final int MIN_NAME_LENGTH = 1;
    private static final int MAX_NAME_LENGTH = 10;

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

    @NotNull
    @Column(length = 10)
    private String name;

    @OneToMany(mappedBy = "study")
    private List<MemberProgress> memberProgresses = new ArrayList<>();
    
    ...
}

 

MemberProgress가 이 1:다 관계에서 다 쪽이기 때문에 연관 관계의 주인(외래키 관리자)이다.

Study 엔티티에서 참여한 멤버들의 스터디 참여 정보를 조회하기 위해 Study쪽에서도 OneToMany(mappedBy = "study")로 양방향 맵핑을 해주었다.

하지만 맵핑을 이렇게 해주니 다음 로직에서 문제가 발생했다.

public void participate(Member member) {
        validateDuplicatedNickname(member);
        MemberProgress pomodoroProgress = new PomodoroProgress(this, member);
        memberProgresses.add(pomodoroProgress);
}

 

member와 이 study에 해당하는 PomodoroProgress 엔티티를 생성하여 meberProgresses.add로 리스트에 추가를 해주었으나 PomodoroProgress가 실제로 영속화되는 과정은 없다.

이에 대한 해결책으로 다음 두 방법을 생각했다.

  1. 영속성 전이로 Study가 영속될 때 MemberProgress도 영속되도록 cascade = CascadeType.PERSIST 옵션을 준다.
  2. 로직을 엔티티에서 수행하지 않고 서비스로 빼내서 MemberProgressRepository.save()를 호출하여 직접 영속화한다.

테오의 의견은 2번으로 아직 JPA가 익숙지 않아 cascade를 사용해서 어떤 문제가 발생할지 모르기 때문에 일단 서비스로 빼서 명시적으로 영속화를 해주자고 했다.

JPA가 익숙하지 않은 것에는 동의하여 일단 서비스로 빼내었으나, 이와 관련한 검증 로직도 서비스로 빼야했다. 로직이 분산되어 응집성이 떨어져 관리가 어렵다는 문제도 있고, 실제로 에러도 발생했다.

서비스로 로직을 뺴내려면 당장 배포를 해야하는 상황에서 테스트 코드도 수정해야 하고, 수정해야 할 코드도 많아져서 우선은 cascade를 붙여서 해결하고 다음에 좀 더 이야기를 해보기로 했다.

문제를 해결하고 천천히 다시 생각해보았다.

이렇게 서비스로 로직을 빼내게 되면 도메인 모델 패턴보다는 트랜잭션 스크립트 패턴에 가까워 진다고 생각하고 이렇게 되면 굳이 객체지향적 코드를 작성하고, 관계형 DB에 저장하기 위해 JPA를 사용하는 의미가 퇴색된다고 생각이 들었다.

또한, 엔티티마다 전부 repository를 만들어서 직접 저장하기 보다 엔티티의 생명 주기가 종속적인 경우에는 영속성 전이를 통해 저장하는 것이 좋겠다고 생각했다.

로직을 수행할 때 어느 엔티티를 불러와서 수행할지도 팀원들과 얘기를 해봐야 할 것 같다. 현재는 중구난방으로 엔티티를 불러와 로직을 수행하는데, 진입점을 잘 정하고 일관되게 리팩토링하는 과정이 필요하다고 생각한다.

'회고 > 우아한테크코스' 카테고리의 다른 글

2023.08.08 일일 회고  (0) 2023.08.09
2023.07.31 일일 회고  (1) 2023.08.01
2023.07.04 일일 회고  (0) 2023.07.05
2023.06.20 일일 회고  (0) 2023.06.21
2023.06.17 일일 회고  (0) 2023.06.18

github action 과 jenkins 는 모두 소프트웨어의 빌드, 테스트, 전달 배포 과정을 자동화할 수 있는 도구이다.

jenkins는 비교적 오랫동안 사용되었고, github action은 2018년에 출시한 새로운 기술이다.

 

Jenkins와 Github Action

Jenkins는 다소 오랜 기간 동안 사용된 기술로, 플러그인 관리, 비용 문제, 일관성 없는 동시 빌드 환경 등과 같은 문제가 있다.

 반면에, Github Action은 플러그인 의존성 문제가 덜하고, 클라우드 환경에서 실행되므로 추가적인 서버 설정 없이 파이프라인을 구성할 수 있다는 장점이 있다. 물론, 로컬 서버에서 구동하는 것도 가능하다.

 

병렬 job 수행

jenkins는 파이프라인의 각 단계를 동기적으로 실행한다. 예를 들어, 단위 테스트, 통합 테스트 및 몇몇의 Sonar 검증을 실행해야 하는 경우, 단일 서버 환경에서 실행해야 한다.
github action은 job들을 병렬적으로 수행하기 떄문에 더 빠른 통합이 가능하다.
(Jenkins에서도 이러한 셋팅이 가능하지만 직접 세팅하기 위해선 추가적인 지식과 노력이 필요하다.)

 

CI 수행 후 댓글 달기

구축하려고 했던 CI과정에서 sonarqube를 통한 코드 정적 분석을 수행하고 이 결과 리포트를 pr의 댓글로 자동으로 달아서 pr에서 쉽게 확인할 수 있게 하고자 했다.

이를 구현하고자 먼저 jenkins에서 방법을 찾았고, Github Pull Request Builder 플러그인을 사용하면 해결할 수 있었다.

 

 

하지만 위와 같이 해당 플러그인은 취약점이 존재했기 때문에 사용하기엔 위험하다고 판단했다.

따라서 jenkins 파이프라인에서 pr에 댓글을 다려면 github api를 이용하여 직접 구현해야 하고, 이는 쉽지 않은 작업이다.

이와는 반대로 github action은 마켓플레이스에서 다른 사람이 작성한 action을 재사용하여 pr에 댓글을 달아주는 과정을 쉽게 구현할 수 있었다. 마켓플레이스 링크

 

 

github-hosted-runner에서 제공하는 클라우드의 성능

github action의 workflow는 github에서 제공하는 클라우드에서 실행되는데 이를 github-hosted-runner라고 하고,

사용자가 직접 서버를 셋팅하여 workflow를 실행하도록 하는 경우 self-hosted-runner라고 한다.

  • public Repository는 github-hosted-runner나 self-hosted-runner나 무료이다.
  • private Repository는 github-hosted-runner인 경우 요금이 발생한다.

다음은  github-hosted-runner에서 무료로 제공하는 클라우드 성능이다.

Hardware specification for Windows and Linux virtual machines:

  • 2-core CPU (x86_64)
  • 7 GB of RAM
  • 14 GB of SSD space

Hardware specification for macOS virtual machines:

  • 3-core CPU (x86_64)
  • 14 GB of RAM
  • 14 GB of SSD space

Hardware specification for macOS XL virtual machines:

  • 12-core CPU (x86_64)
  • 30 GB of RAM
  • 14 GB of SSD space

AWS에서 제공하는 EC2 프리티어의 RAM은 1GB~2GB 정도이다.

필자는 EC2 프리티어에서 jenkins 파이프라인을 수행하면서 메모리가 부족해 인스턴스가 중지되는 불편한 경험을 했다. (메모리 문제는 스왑 메모리 설정으로 어느 정도 커버는 가능하다.)

이를 해결하려면 인스턴스를 추가로 할당하여 특정 파이프라인의 stage용도로 사용해야 한다.(ex. 빌드만 수행하는 인스턴스)

이에 비하여 github action은 workflow를 수행하기 위한 서버를 세팅할 필요도 없고 성능도 넉넉하다.

 

마치며

Jenkins와 Github Action 둘 다 강점이 있지만, 어떤 것이 더 나은지는 사용하려는 파이프라인 설계와 필요한 기능들에 따라 달라진다. 

Jenkins는 자유도와 유연성이 더 높지만, Github Action은 설정이 훨씬 간편하다.

 개발자는 구축하려는 CI/CD 파이프라인과 필요한 기능들을 잘 고려하여 적절한 도구를 선택해야 한다.

 

참고 문서

+ Recent posts