네트워크 통신과 멱등성

DaeChan Jo·2023년 9월 25일
0

WEB

목록 보기
1/2
post-thumbnail

멱등성의 의미

멱등(idempotent) 이라는 단어는 수학에서 유래되었다고 한다. 어떤 연산을 여러 번 적용하더라도 결과가 변하지 않는 성질을 가리킨다. 이 성질은 분산 시스템 또는 네트워크 통신 환경에서도 유용하게 사용할 수 있다

HTTP 메서드와 멱등성

HTTP 프로토콜의 설계 원칙 중 하나가 바로 멱등성이다.
좀 더 쉽게 설명하자면 HTTP 통신에서의 멱등성이란, 동일한 요청이 여러번 수행되어도 동일한 응답값을 줄 수 있다면 멱등성이 지켜지고 있다고 볼 수 있다. 보통 멱등성을 가져야하는 메서드는 다음과 같다.

  • GET : 리소스를 조회하는 용도로 사용되며 서버의 상태를 변경시키지 않는다. 따라서 같은 GET 요청에 대해서 항상 같은 응답이 반환되어야 한다.

  • PUT : 리소스를 지정한 상태로 만든다. 이미 지정된 상태와 동일한 상태로 요청하더라도 서버의 상태가 바뀌지 않는다.

  • DELETE : 리소스를 삭제하는데 사용된다. 이미 삭제된 리소스에 대해 다시 DELETE 요청을 보내더라도 그 결과에는 변화가 없다

반면 POST 메서드는 원칙적으로 비멱등 메서드이다. POST 요청은 서버의 상태를 변경시키므로 같은 요청을 여러 번 보내면 서버의 상태가 여러번 바뀔 수 있다.

그렇다면 POST 메서드도 무조건 멱등하게 설계를 해야하는가?
꼭 그렇지만은 않지만 분산 시스템에서 네트워크 장애 등으로 동일한 요청이 중복해서 발생할 수 있으므로, 가능한 한 많은 연산들이 멱등하도록 설계되는게 좋다고 볼 수 있다. 그러면 클라이언트가 안전하게 같은 요청을 다시 보낼 수 있고, 그 결과로 시스템 전체의 안정성과 일관성을 유지할 수 있다.


예시로 가장 기본적인 회원가입 API를 살펴보자

export const createUser = async (
    req: Request,
    res: Response,
    next: NextFunction,
) => {
    try {
        const { email, password, name, nickname } = req.body;
        const { emailExists, nicknameExists } =
            await authService.signUpDuplicateCheck(email, nickname);
        if (emailExists)
            return res
                .status(409)
                .json({ message: "이미 존재하는 이메일입니다." });
        if (nicknameExists)
            return res
                .status(409)
                .json({ message: "이미 존재하는 닉네임입니다." });
        const hashedPassword = await bcrypt.hash(password, 10);
        const newUser = await authService.createUser({
            email,
            name,
            nickname,
            password: hashedPassword,
        });
        return res.status(201).json({
            message: `회원가입에 성공했습니다 :: ${newUser.nickname}`,
        });
    } catch (error) {
        console.error(error);
        next(error);
    }
};

사용자가 입력한 이메일과 닉네임이 중복검사를 통과하면 회원가입에 성공하고 201을 반환하지만, 만약 동일한 정보로 다시 요청을 보내면 409를 반환하게 설계되어있다. 즉 같은 요청을 반복할 때 동일한 응답을 하지 않기에 멱등성이 지켜지지 않다고 볼 수 있다.

이를 멱등하게 만들 가장 간단한 방법은 이미 존재하는 유저가 있을 경우 아무런 작업도 수행하지 않고 성공 메시지만 반환해버리면 된다.

하지만 그렇게되면 사용자에게 정확한 정보를 제공하지 못하게 된다.
멱등성과 사용자 경험을 동시에 만족시키기 위해서 먼저 유효성 검사를 수행하는 방법을 고려해볼 수 있다. 예를 들어 다음과 같이 이메일, 닉네임 유효성검사 API를 새롭게 추가한다

export const checkEmailOrNickname = async (
    req: Request,
    res: Response,
    next: NextFunction,
) => {
    /**
     * #swagger.tags = ['Auth']
     * #swagger.summary = '회원가입 이메일 및 닉네임 중복 체크'
     */
    try {
        const email = req.query.email as string;
        const nickname = req.query.nickname as string;

        if (email) {
            const existingUserEmail = await authService.getUserByEmail(email);
            if (existingUserEmail)
                return res
                    .status(409)
                    .json({ message: "이미 사용중인 이메일 입니다." });
            else
                return res
                    .status(200)
                    .json({ message: "사용 가능한 이메일 입니다." });
        }

        if (nickname) {
            const existingUserNickname =
                await authService.getUserByNickname(nickname);
            if (existingUserNickname)
                return res
                    .status(409)
                    .json({ message: "이미 사용중인 닉네임 입니다." });
            else
                return res
                    .status(200)
                    .json({ message: "사용 가능한 닉네임 입니다." });
        }
    } catch (error) {
        console.error(error);
        next(error);
    }
};

checkEmail 과 checkNickname 는 동일한 요청에 같은 응답을 반환하므로 멱등한 상태이다.

해당 함수를 이용해 회원가입 요청을 보내기 전에 폼에서 이메일 필드가 변경될 때마다 서버에 '이메일 중복 확인' 요청을 보내서 이미 등록된 이메일인지 미리 확인할 수 있다. 그러면 사용자가 실제로 회원가입 요청을 보내기 전에 이미 사용중인 이메일인지 알 수 있으므로, 불필요한 요청을 줄일 수 있고 사용자 경험도 향상시킬 수 있다.

단, 클라이언트 측에서의 유효성 검사만으론 충분하지 않기에 서버사이드에서도 동일한 유효성 검사를 수행하는게 안전성 측면에서 좋다.

GET, PUT, DELETE 는 항상 멱등한가

앞서 얘기할 때 POST를 제외하곤 원칙적으로 '멱등하다' 라고 할 수 있다 했지만 항상 그렇지는 않다. 다음은 게시글을 조회하고 조회수를 증가시키는 함수다

export const getPosts = async (
    req: Request,
    res: Response,
    next: NextFunction,
) => {
    try {
        const postId = Number(req.query.postId);
        const userId = Number(req.query.userId);
        const page = Number(req.query.page);
        const limit = Number(req.query.limit);
        if (postId) {
            const post = await postService.getPostByPostId(postId);
            if (!post)
                return res
                    .status(404)
                    .json({ message: "존재하지 않는 게시글입니다." });
            const updatedViewCountPost =
                await postService.updatePostViewCount(postId);
          
            return res.status(200).json(updatedViewCountPost);
        } else if (userId) {
            const posts = await postService.getPostsByUserId(
                userId,
                page,
                limit,
            );
            return res.status(200).json(posts);
        } else {
            const posts = await postService.getAllPosts(page, limit);
            return res.status(200).json(posts);
        }
    } catch (error) {
        console.error(error);
        next(error);
    }
};

쿼리로 postId를 받을 시, 게시글을 조회함과 동시에 조회수 카운터를 증가시킨다. 이는 해당 요청이 반복될 때 마다 다른 값을(증가된 조회수) 를 반환하기에 멱등하지 않다.
이를 멱등하게 변경하려면 마찬가지로 조회수증가 API를 추가하면 되지만 무조건적으로 옳은 방법이라곤 할 수 없다.

각각의 API로 분리할 경우 멱등성을 유지할 수 있지만, 조회수 증가 요청보다 게시글조회 요청 응답이 먼저 도착하는 상황 등 순서 보장문제를 완전히 해결할 수 없다. 반대로 하나의 트랜잭션으로 처리하면 원자성과 순서 보장이 가능하지만 복잡성과 성능 문제 등이 발생할 수 있다.

따라서 가장 이상적인 방법은 상황과 요구 사항에 따라 다르므로 각 방법의 장단점을 고려하여 결정하는게 좋다.

profile
BackEnd Developer

0개의 댓글